pi-continual-harness 0.7.0 → 0.8.1

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
@@ -67,6 +67,9 @@ Durable I/O (round-trip with pi-reflect):
67
67
  /harness push-mem [--all|--kind <kind>|--model <provider/id|active>] # persist active items to pi-mem (save_memory)
68
68
  ```
69
69
 
70
+ Subcommands, flags, `--kind`/`--model` values, and `keep`/`drop` item ids all
71
+ autocomplete in the TUI as you type after `/harness `.
72
+
70
73
  `import` reconciles the file into the live store: items whose id matches an
71
74
  existing entry are updated (offline edits win on content/evidence/importance),
72
75
  new entries are created. By default nothing is deleted — `--prune` also drops
@@ -116,6 +119,7 @@ Optional config at `~/.pi/agent/harness.json` (missing or malformed → defaults
116
119
  {
117
120
  "durableScope": "global",
118
121
  "proposer": "steering",
122
+ "injection": { "enabled": true, "maxTokens": 1500, "maxPerKind": 10, "charsPerToken": 4 },
119
123
  "remindRefine": { "enabled": false, "everyTurns": 50 },
120
124
  "autoRefine": { "enabled": false, "everyTurns": 100, "commit": false },
121
125
  "outcomeImportance": { "enabled": false, "bump": 0.03 }
@@ -126,6 +130,11 @@ Optional config at `~/.pi/agent/harness.json` (missing or malformed → defaults
126
130
  `~/.pi/agent/harness-state.md`; `"project"` writes to
127
131
  `~/.pi/agent/harness-state/<slug>.md` (slug derived from `cwd`) so each
128
132
  project keeps separate state for pi-reflect.
133
+ - **`injection`** — the selection policy for WHAT gets surfaced in the system
134
+ prompt each turn (see [Injection selection](#injection-selection-on-by-default)).
135
+ ON by default: items are importance-ordered, capped at `maxPerKind` (default
136
+ 10) per kind and `maxTokens` (default 1500) total. Set `enabled: false` to
137
+ restore the legacy "inject all items, in store order" behaviour.
129
138
  - **`proposer`** — which delta proposer `/refine` and auto-refine use. Defaults
130
139
  to `steering` (the agent reasons via a steering message). `dedupe` applies a
131
140
  rule-based dedupe directly. See [Proposers](#proposers).
@@ -204,6 +213,47 @@ In particular, `/harness push-mem` pushes *every* model's active items into
204
213
  pi-mem by default (which can yield near-duplicate memories across models); pass
205
214
  `--model <provider/id|active>` to scope it to one model.
206
215
 
216
+ ## Injection selection (on by default)
217
+
218
+ The harness ACCUMULATES notes (create / refine / auto-refine / outcome-promotion),
219
+ but the system prompt is finite. So since 0.8 the block appended each turn is the
220
+ result of a **selection policy**, not the whole store — and it is **on by
221
+ default**. The store itself is never changed by selection (nothing is lost);
222
+ only what is *surfaced* changes.
223
+
224
+ The policy (pure, deterministic; `src/select.ts`):
225
+
226
+ 1. **Filter** — only active items bound to the active model (strict per-model
227
+ isolation, unchanged).
228
+ 2. **Order** — importance desc; ties keep store/insertion order (stable). The
229
+ highest-fitness notes lead each section.
230
+ 3. **Cap** — `maxPerKind` (default 10: balanced sections, no single kind drowns
231
+ the block), then `maxTokens` (default 1500: total budget). The budget is
232
+ filled **round-robin across kinds by importance rank**, so one kind cannot
233
+ starve the others; within that order an item that doesn't fit is **skipped**
234
+ (not a hard stop), so a large item never blocks smaller higher-priority ones.
235
+
236
+ The defaults are deliberately generous — a **no-op for small stores** (nothing
237
+ trimmed) and protective as the harness grows. When the policy drops items, the
238
+ block ends with a one-line transparency note:
239
+
240
+ ```
241
+ _(3 item(s) not shown — below the injection budget. Raise `injection.maxTokens`/`maxPerKind` in harness.json or run `/harness prune`.)_
242
+ ```
243
+
244
+ Tune or disable it in `harness.json`:
245
+
246
+ ```json
247
+ { "injection": { "maxTokens": 3000, "maxPerKind": 20 } } // raise the ceiling
248
+ { "injection": { "maxPerKind": 5 } } // trim harder, per kind
249
+ { "injection": { "enabled": false } } // opt out: legacy "all, in order"
250
+ ```
251
+
252
+ Selection is factored into a pure function (`selectForInjection`) and
253
+ re-exported from the package entry, so a companion package can layer richer
254
+ policies (e.g. relevance to the current turn, via the shared `tokenize` /
255
+ `tokenOverlap` helpers) without touching `inject.ts`.
256
+
207
257
  ## Proposers
208
258
 
209
259
  `/refine` is split into two stages: **propose** (given evidence + state, decide
@@ -263,7 +313,7 @@ Then `"my-proposer"` is selectable via `/refine --proposer my-proposer` or
263
313
 
264
314
  ## Status
265
315
 
266
- 0.7.x. Implemented:
316
+ 0.8.x. Implemented:
267
317
 
268
318
  - Unified harness-state store with branch-local snapshots (`/tree` rollback).
269
319
  - Online `/refine` + `harness_mutate` / `harness_list` tools.
@@ -282,6 +332,10 @@ Then `"my-proposer"` is selectable via `/refine --proposer my-proposer` or
282
332
  only for that model; new items are stamped automatically and orphans adopted
283
333
  on first contact. A new model id starts from a blank harness, and the durable
284
334
  round-trip preserves the owner tag.
335
+ - **Bounded injection (on by default)**: the supplemental block is
336
+ importance-ordered and capped per kind + by a total token budget, so a growing
337
+ harness never balloons the system prompt. Tunable / opt-out via the `injection`
338
+ config key; the store is never changed by selection.
285
339
 
286
340
  Open extension points (see `docs/ROADMAP.md`):
287
341
 
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.1",
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
@@ -4,6 +4,10 @@
4
4
  // /harness export [path] write active items to a markdown file
5
5
  // /harness import [--prune] [path] parse it back and merge (durable wins)
6
6
  //
7
+ // The command registers getArgumentCompletions so the TUI offers a filtered
8
+ // menu of subcommands (and, one level deeper, flags / item ids / kinds) as
9
+ // you type — see completions() below. The handler itself stays parsing-only.
10
+ //
7
11
  // export writes the active items to ~/.pi/agent/harness-state.md (best-effort);
8
12
  // import parses that file and merges into the live store. Because pi-reflect
9
13
  // edits markdown files and git-commits, pointing it at the same file closes the
@@ -23,15 +27,152 @@ import {
23
27
  reconstructFromDurable,
24
28
  } from "./store.js";
25
29
  import { loadConfig, resolveDurablePath } from "./config.js";
30
+ import { KIND_LABEL } from "./types.js";
26
31
  import type { ComponentKind, HarnessItem } from "./types.js";
27
32
 
33
+ /** One row of the /harness subcommand menu (label = name, value = name). */
34
+ interface CompletionEntry {
35
+ name: string;
36
+ description: string;
37
+ }
38
+
39
+ const SUBCOMMANDS: CompletionEntry[] = [
40
+ { name: "import", description: "Import durable state (--prune to prune stale items)" },
41
+ { name: "export", description: "Export active items to durable file" },
42
+ { name: "status", description: "Show harness status (active/total, per-kind counts, durable file)" },
43
+ { name: "prune", description: "Decay & prune inactive items (--decay <days>)" },
44
+ { name: "keep", description: "Bump item importance (+0.1)" },
45
+ { name: "drop", description: "Lower item importance (−0.1)" },
46
+ { name: "push-mem", description: "Persist active items to pi-mem (--all, --kind, --model)" },
47
+ ];
48
+
49
+ const KINDS: ComponentKind[] = ["prompt", "memory", "skill", "subagent"];
50
+
51
+ /** Flags each subcommand accepts, in menu order. */
52
+ const FLAGS: Record<string, CompletionEntry[]> = {
53
+ import: [{ name: "--prune", description: "Drop live items missing from the durable file" }],
54
+ prune: [{ name: "--decay", description: "Decay items not updated for <days> before pruning" }],
55
+ "push-mem": [
56
+ { name: "--all", description: "Push every active item, not just memories" },
57
+ { name: "--kind", description: `Limit to one kind (${KINDS.join("|")})` },
58
+ { name: "--model", description: "Limit to one owner model (provider/id or \"active\")" },
59
+ ],
60
+ };
61
+
62
+ function preview(text: string, max = 60): string {
63
+ // Single line + truncated so the menu row stays readable.
64
+ const flat = text.replace(/\s+/g, " ").trim();
65
+ return flat.length > max ? `${flat.slice(0, max)}…` : flat;
66
+ }
67
+
68
+ /**
69
+ * Argument autocomplete for /harness (pi calls this with the FULL text after
70
+ * the command name; a selected item's `value` REPLACES that whole text, so
71
+ * multi-token rows re-emit the tokens already typed). Two levels:
72
+ *
73
+ * /harness <partial-subcommand> → matching subcommands
74
+ * /harness <sub> …<partial-token> → flags / ids / kind+model values for sub
75
+ *
76
+ * Returns null where a menu would only get in the way (path arguments,
77
+ * numeric flag values, unknown subcommands) so the editor falls back to its
78
+ * own behavior.
79
+ */
80
+ function completions(argumentPrefix: string) {
81
+ // Level 1 — still typing the first token: the subcommand menu.
82
+ if (!/\s/.test(argumentPrefix)) {
83
+ const q = argumentPrefix.toLowerCase();
84
+ return filterSubcommands(q);
85
+ }
86
+ const tokens = argumentPrefix.trim().split(/\s+/).filter(Boolean);
87
+ // Fresh token (trailing whitespace) → the token being completed is "";
88
+ // otherwise it is the last typed token. `before` are the committed ones.
89
+ const fresh = argumentPrefix !== argumentPrefix.trimEnd();
90
+ const last = fresh ? "" : (tokens.at(-1) ?? "");
91
+ const before = fresh ? tokens : tokens.slice(0, -1);
92
+
93
+ // A stray leading space ("/harness imp") still means level 1, matching the
94
+ // handler's tolerant split.
95
+ if (before.length === 0) return filterSubcommands(last.toLowerCase());
96
+
97
+ const sub = before[0]!.toLowerCase();
98
+ // Flag-value completion: the token right before the one being typed.
99
+ const prev = before.at(-1)!;
100
+ if (prev === "--kind" && sub === "push-mem") {
101
+ return filterValues(
102
+ KINDS.map((k) => ({ value: k, description: KIND_LABEL[k] })),
103
+ last,
104
+ before,
105
+ );
106
+ }
107
+ if (prev === "--model" && sub === "push-mem") {
108
+ const owners = [...new Set(getState().items.filter((i) => i.active && i.ownerModel).map((i) => i.ownerModel))];
109
+ return filterValues(
110
+ [
111
+ { value: "active", description: "The model driving this command" },
112
+ ...owners.map((m) => ({ value: m, description: "Items owned by this model" })),
113
+ ],
114
+ last,
115
+ before,
116
+ );
117
+ }
118
+
119
+ // Flag completion ("--…" prefix, or a fresh token for flag-taking subs).
120
+ // A fresh --kind/--model value was already completed above (early return),
121
+ // so a fresh token here means "offer the remaining flags".
122
+ if (last.startsWith("--") || (fresh && sub in FLAGS)) {
123
+ const used = new Set(before.filter((t) => t.startsWith("--")));
124
+ const rows = (FLAGS[sub] ?? []).filter((f) => !used.has(f.name));
125
+ const items = rows
126
+ .filter((f) => f.name.startsWith(last))
127
+ .map((f) => ({ value: [...before, f.name].join(" "), label: f.name, description: f.description }));
128
+ return items.length > 0 ? items : null;
129
+ }
130
+
131
+ // Positional id completion for keep/drop: the one argument they take.
132
+ if (sub === "keep" || sub === "drop") {
133
+ const idCommitted = before.slice(1).some((t) => !t.startsWith("--"));
134
+ if (idCommitted) return null; // id already chosen; nothing left to complete
135
+ const items = getState()
136
+ .items.filter((i) => i.active && i.id.startsWith(last))
137
+ .map((i: HarnessItem) => ({
138
+ value: [...before, i.id].join(" "),
139
+ label: i.id,
140
+ description: `${i.kind} · ${preview(i.content)}`,
141
+ }));
142
+ return items.length > 0 ? items : null;
143
+ }
144
+
145
+ // Everything else is a path/number/free-text argument — no menu.
146
+ return null;
147
+ }
148
+
149
+ function filterSubcommands(prefix: string) {
150
+ const items = SUBCOMMANDS.filter((s) => s.name.startsWith(prefix)).map((s) => ({
151
+ value: s.name,
152
+ label: s.name,
153
+ description: s.description,
154
+ }));
155
+ return items.length > 0 ? items : null;
156
+ }
157
+
158
+ /** Flag-value completion: `value` = committed tokens + the chosen value. */
159
+ function filterValues(
160
+ rows: Array<{ value: string; description: string }>,
161
+ prefix: string,
162
+ before: string[],
163
+ ) {
164
+ const items = rows
165
+ .filter((r) => r.value.startsWith(prefix))
166
+ .map((r) => ({ value: [...before, r.value].join(" "), label: r.value, description: r.description }));
167
+ return items.length > 0 ? items : null;
168
+ }
169
+
28
170
  export function registerHarness(pi: ExtensionAPI): void {
29
171
  pi.registerCommand("harness", {
30
172
  description:
31
- "Durable harness-state I/O + importance hygiene. Subcommands: " +
32
- "import [--prune] [path] · export [path] · status [path] · " +
33
- "prune [--decay <days>] · keep <id> · drop <id> · " +
34
- "push-mem [--all|--kind <kind>|--model <provider/id|active>] (persist active items to pi-mem).",
173
+ "Durable harness-state I/O + importance hygiene " +
174
+ "(import · export · status · prune · keep · drop · push-mem — " +
175
+ "subcommands autocomplete as you type)",
35
176
  handler: async (args, ctx) => {
36
177
  const parts = args.trim().split(/\s+/).filter(Boolean);
37
178
  const sub = (parts[0] ?? "status").toLowerCase();
@@ -61,6 +202,7 @@ export function registerHarness(pi: ExtensionAPI): void {
61
202
  return;
62
203
  }
63
204
  },
205
+ getArgumentCompletions: (argumentPrefix) => completions(argumentPrefix),
64
206
  });
65
207
  }
66
208
 
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
+ }