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 +55 -1
- package/package.json +1 -1
- package/src/config.ts +12 -0
- package/src/harness.ts +146 -4
- package/src/index.ts +13 -0
- package/src/inject.ts +38 -17
- package/src/select.ts +184 -0
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.
|
|
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.
|
|
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
|
|
32
|
-
"import
|
|
33
|
-
"
|
|
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.
|
|
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
|
+
}
|