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 +52 -1
- package/package.json +1 -1
- package/src/config.ts +12 -0
- package/src/index.ts +13 -0
- package/src/inject.ts +38 -17
- package/src/select.ts +184 -0
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.
|
|
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.
|
|
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.
|
|
27
|
-
*
|
|
28
|
-
*
|
|
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
|
|
33
|
-
if (
|
|
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
|
|
37
|
-
const
|
|
38
|
-
if (
|
|
39
|
-
const bullets =
|
|
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
|
-
|
|
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
|
-
]
|
|
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
|
-
|
|
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
|
+
}
|