pi-continual-harness 0.13.0 → 0.15.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 +115 -25
- package/package.json +1 -1
- package/src/auto-refine.ts +35 -11
- package/src/config.ts +161 -25
- package/src/corpus.ts +30 -10
- package/src/durable.ts +179 -5
- package/src/harness.ts +233 -20
- package/src/hints.ts +67 -0
- package/src/index.ts +71 -5
- package/src/inject.ts +58 -2
- package/src/outcome.ts +12 -2
- package/src/remind.ts +252 -26
- package/src/setup.ts +350 -0
- package/src/status.ts +340 -0
- package/src/store.ts +27 -5
- package/src/tools.ts +72 -1
package/README.md
CHANGED
|
@@ -70,11 +70,13 @@ Durable I/O (round-trip with pi-reflect):
|
|
|
70
70
|
/harness drop <id> # nudge importance down (−0.1)
|
|
71
71
|
/harness move <id> global|project # move an item between durable layers
|
|
72
72
|
/harness split # steer the agent to classify every item's scope
|
|
73
|
+
/harness adopt --from <provider/id> [--all] # steer the agent to copy another model's items here
|
|
73
74
|
/harness push-mem [--all|--kind <kind>|--model <provider/id|active>] # persist active items to pi-mem (save_memory)
|
|
75
|
+
/harness setup [--preset <quiet|recommended|power>] [--yes] # guided config wizard
|
|
74
76
|
```
|
|
75
77
|
|
|
76
|
-
Subcommands, flags, `--kind`/`--model` values, `move` scopes, and
|
|
77
|
-
`drop`/`move` item ids all autocomplete in the TUI as you type after
|
|
78
|
+
Subcommands, flags, `--kind`/`--model`/`--from` values, `move` scopes, and
|
|
79
|
+
`keep`/`drop`/`move` item ids all autocomplete in the TUI as you type after
|
|
78
80
|
`/harness `.
|
|
79
81
|
|
|
80
82
|
**Durable layers.** Every item carries its own durable scope: `global`
|
|
@@ -104,7 +106,10 @@ The model-facing tools:
|
|
|
104
106
|
|
|
105
107
|
- `harness_list({ kind?, model? })` — read current state. `model` defaults to
|
|
106
108
|
the active model's items (what gets injected this turn); `"*"` returns every
|
|
107
|
-
model.
|
|
109
|
+
model. Empty results are **diagnostic**: the message distinguishes a
|
|
110
|
+
genuinely empty store (with zero-state guidance) from items hidden by the
|
|
111
|
+
model or kind filter (with a tally and a `model: "*"` pointer), so an
|
|
112
|
+
empty list never reads as "broken".
|
|
108
113
|
- `harness_mutate { deltas: [...] }` — apply a batch of `create` / `update` /
|
|
109
114
|
`delete` deltas. Every `create` requires `evidence`. New items are stamped
|
|
110
115
|
automatically with the active model (see [Model binding](#model-binding-per-model-isolation)).
|
|
@@ -136,15 +141,39 @@ unchanged by a push — pi-mem gets a separate copy.
|
|
|
136
141
|
|
|
137
142
|
## Configuration
|
|
138
143
|
|
|
144
|
+
**First-run setup:** instead of hand-editing the JSON below, run
|
|
145
|
+
`/harness setup`. It is a guided wizard:
|
|
146
|
+
|
|
147
|
+
1. Shows the **current effective config** (your file merged over the shipped
|
|
148
|
+
defaults) plus the durable-layer paths.
|
|
149
|
+
2. Offers three **presets**: `quiet` (pin every opt-in to the shipped
|
|
150
|
+
defaults), `recommended` (`autoImport` + import reminders on), `power`
|
|
151
|
+
(`recommended` + `autoRefine`). Each preset is shown as a declarative diff
|
|
152
|
+
before anything is applied.
|
|
153
|
+
3. Confirms **each delta** with a one-line rationale. Autonomous switches
|
|
154
|
+
get their own explicit "this lets the agent mutate state on a cadence"
|
|
155
|
+
confirmation regardless of preset.
|
|
156
|
+
4. Writes via one reviewed path: backs up the existing file
|
|
157
|
+
(`harness.json.bak-<timestamp>`), atomically writes the merged result,
|
|
158
|
+
then reloads and prints the effective config exactly as pi will read it.
|
|
159
|
+
|
|
160
|
+
For scripted setups: `/harness setup --preset recommended --yes` (the `--yes`
|
|
161
|
+
flag is the scripted form of the confirmations, so it enables autonomous
|
|
162
|
+
features without prompts). Nothing is written on unknown input, a cancelled
|
|
163
|
+
preset menu, or a declined final write; declining an individual feature only
|
|
164
|
+
skips that feature (the accepted deltas still apply). The wizard never
|
|
165
|
+
touches harness state, only the config file.
|
|
166
|
+
|
|
139
167
|
Optional config at `~/.pi/agent/harness.json` (missing or malformed → defaults):
|
|
140
168
|
|
|
141
169
|
```json
|
|
142
170
|
{
|
|
143
171
|
"autoImport": false,
|
|
172
|
+
"importRemind": { "enabled": true, "everyTurns": 25 },
|
|
144
173
|
"proposer": "steering",
|
|
145
174
|
"dedupe": { "threshold": 0.6, "merge": true },
|
|
146
175
|
"injection": { "enabled": true, "maxTokens": 1500, "maxPerKind": 10, "charsPerToken": 4 },
|
|
147
|
-
"remindRefine": { "enabled":
|
|
176
|
+
"remindRefine": { "enabled": true, "everyTurns": 25, "detailEvery": 4 },
|
|
148
177
|
"autoRefine": { "enabled": false, "everyTurns": 100, "commit": false, "gate": false },
|
|
149
178
|
"outcomeImportance": { "enabled": false, "bump": 0.03 },
|
|
150
179
|
"classifier": { "model": "typesafe/jev-latest" },
|
|
@@ -160,7 +189,21 @@ Optional config at `~/.pi/agent/harness.json` (missing or malformed → defaults
|
|
|
160
189
|
changed since the last export. This is what makes `/refine` output survive
|
|
161
190
|
into **new** sessions without manual export/import ceremony. Both halves are
|
|
162
191
|
visible (one notify line) and use the same persisted `harness-state` entries
|
|
163
|
-
as every other mutation (`/tree` rollback covers them).
|
|
192
|
+
as every other mutation (`/tree` rollback covers them). Enabling it
|
|
193
|
+
mid-session (e.g. via `/harness setup`) is safe: the first `turn_end`
|
|
194
|
+
imports the layers *before* any export, so a late enable can never
|
|
195
|
+
overwrite the durable files with a never-imported store.
|
|
196
|
+
- **`importRemind`** — **awareness reminders** for that sync (effective only
|
|
197
|
+
when `autoImport` is on; default `{ "enabled": true, "everyTurns": 25 }`).
|
|
198
|
+
The session-start import doesn't just count — it **lists what arrived**
|
|
199
|
+
(item id, kind, first words of content), and every `everyTurns` turns a
|
|
200
|
+
one-line reminder re-surfaces the imported items so silently-injected
|
|
201
|
+
instructions never go unnoticed (full list via the agent's `harness_list`
|
|
202
|
+
tool — `/harness status` shows only counts). Bounded: at most 3 reminders
|
|
203
|
+
per imported batch, then quiet until the next session; aborted/error turns
|
|
204
|
+
are inert (completed-turn cadence). Kill-switch:
|
|
205
|
+
`{ "importRemind": { "enabled": false } }` (the start-of-session listing
|
|
206
|
+
still fires).
|
|
164
207
|
- **`injection`** — the selection policy for WHAT gets surfaced in the system
|
|
165
208
|
prompt each turn (see [Injection selection](#injection-selection-on-by-default)).
|
|
166
209
|
ON by default: items are importance-ordered, capped at `maxPerKind` (default
|
|
@@ -181,9 +224,20 @@ Optional config at `~/.pi/agent/harness.json` (missing or malformed → defaults
|
|
|
181
224
|
companion packages inject a richer comparator whose return may **abstain**
|
|
182
225
|
per pair (`{ score, abstain }` — see [`SimilarityResult`](#proposers)); an
|
|
183
226
|
abstaining pair is always kept, never merged or deleted.
|
|
184
|
-
- **`remindRefine`** —
|
|
185
|
-
|
|
186
|
-
|
|
227
|
+
- **`remindRefine`** — the `turn_end` **activation nudge**, ON by default
|
|
228
|
+
(informational only; it never mutates state). Every `everyTurns` completed
|
|
229
|
+
turns (default 25) a one-line reminder suggests `/refine`, garnished with
|
|
230
|
+
live data from the store (e.g. "12 active item(s), 4 never cited since
|
|
231
|
+
creation"; an empty store gets first-run guidance instead). Every
|
|
232
|
+
`detailEvery`-th reminder (default 4: at the default cadence, roughly
|
|
233
|
+
every 100 turns) escalates to a rotating educational guide: how the loop
|
|
234
|
+
works (with alternate descriptions so it never reads as boilerplate), the
|
|
235
|
+
`/refine` workflow, `keep`/`drop`/`prune --decay` hygiene, `push-mem`,
|
|
236
|
+
`split`, `export-corpus`, and a recommended starter config. Fresh installs
|
|
237
|
+
used to be silent; that was the bug, not the feature. Aborted/error turns
|
|
238
|
+
are skipped, so `everyTurns` counts completed turns of work. Silence it
|
|
239
|
+
with `{ "remindRefine": { "enabled": false } }`; `detailEvery: 1` makes
|
|
240
|
+
every reminder detailed.
|
|
187
241
|
- **`autoRefine`** — opt-in autonomous self-improvement (**off by default**).
|
|
188
242
|
When `enabled`, the agent runs `/refine` itself every `everyTurns` turns
|
|
189
243
|
(default 100). It is one of the package's opt-in autonomous paths: it reuses
|
|
@@ -195,14 +249,21 @@ Optional config at `~/.pi/agent/harness.json` (missing or malformed → defaults
|
|
|
195
249
|
signal, not just time (errors fall back to the plain cadence). The steering
|
|
196
250
|
prompt is delivered via the pi ≥ 0.87 actionable `turn_end` boundary (a
|
|
197
251
|
structural `harness.auto-refine-request` entry + `continue: true`), not a
|
|
198
|
-
synthetic user message.
|
|
252
|
+
synthetic user message. Aborted/error turns (pi `turn_end`
|
|
253
|
+
`BoundaryState.outcome`) are inert: they neither seed nor advance the
|
|
254
|
+
cadence window — a refine never fires over partial evidence.
|
|
199
255
|
- **`classifier`** — opt-in classifier model (pi ≥ 0.99) for the cheap gated
|
|
200
256
|
paths: `"provider/id"` or a bare id of a **classifier** catalog entry, e.g.
|
|
201
257
|
`"typesafe/jev-latest"` or a local `llama-cpp` classifier (every chat model
|
|
202
|
-
listed for llama.cpp also appears as
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
258
|
+
listed for llama.cpp also appears as one). With pi ≥ 1.1.0 the catalog also
|
|
259
|
+
offers OpenAI's GPT-6 Luna through the Decisions API (e.g.
|
|
260
|
+
`"openai/gpt-6-luna"`, needs `OPENAI_API_KEY`) and the native llama.cpp
|
|
261
|
+
decision models (Julia-1, Laya, Kev, lev, OpenJev through `/v1/systemone`,
|
|
262
|
+
llama.cpp ≥ 0.6.0 — listed *only* as classifiers, which is exactly our use:
|
|
263
|
+
a free local gate). When set and resolvable it enables the
|
|
264
|
+
classifier-confirmed dedupe (see `dedupe` above) and the auto-refine `gate`.
|
|
265
|
+
When unset or unresolvable, every classifier feature degrades to its
|
|
266
|
+
no-classifier behavior.
|
|
206
267
|
- **`virtualBinding`** — how the per-model isolation key treats pi **virtual
|
|
207
268
|
models** (router extensions, pi ≥ 0.99). Default `"virtual"`: the key is
|
|
208
269
|
the selected model's own `provider/id` — a router starts a blank harness.
|
|
@@ -215,19 +276,23 @@ Optional config at `~/.pi/agent/harness.json` (missing or malformed → defaults
|
|
|
215
276
|
- **`outcomeImportance`** — opt-in autonomous **promotion** loop (**off by
|
|
216
277
|
default**). When `enabled`, a `turn_end` hook bumps (+`bump`, default 0.03)
|
|
217
278
|
the importance of any active item the agent cited by its `[h_xxxx]` tag in the
|
|
218
|
-
turn's output.
|
|
279
|
+
turn's output. Citations are read from completed turns only — an aborted
|
|
280
|
+
turn's partial text is scanned by the next completed turn (the initial seed
|
|
281
|
+
turn itself is never scanned, as before). Referenced items
|
|
282
|
+
gain importance and get their `updatedAt`
|
|
219
283
|
touched (so they survive time-based decay); ignored items keep decaying. This
|
|
220
284
|
is the package's second opt-in autonomous path — promotion only, never
|
|
221
285
|
deletes, persisted/branchable like `harness_mutate`. Autonomous demotion from
|
|
222
286
|
outcomes is intentionally NOT done (high false-positive); use `/harness drop`,
|
|
223
287
|
`prune --decay`, or the `dedupe` proposer for that.
|
|
224
288
|
|
|
225
|
-
> **
|
|
226
|
-
> (`/harness move`), and durable I/O
|
|
227
|
-
>
|
|
228
|
-
> Migration from a `durableScope: "project"` setup: run
|
|
229
|
-
> in the project (its file's items adopt project
|
|
230
|
-
> with `/harness move` (or classify the whole
|
|
289
|
+
> **Removed:** the store-wide `durableScope` config key (deprecated 0.9.0,
|
|
290
|
+
> removed now). Items carry their own scope (`/harness move`), and durable I/O
|
|
291
|
+
> is always layered. Config files that still set the key load fine — unknown
|
|
292
|
+
> keys are ignored. Migration from a `durableScope: "project"` setup: run
|
|
293
|
+
> `/harness import` once in the project (its file's items adopt project
|
|
294
|
+
> scope), then move any strays with `/harness move` (or classify the whole
|
|
295
|
+
> store with `/harness split`).
|
|
231
296
|
|
|
232
297
|
## How it works
|
|
233
298
|
|
|
@@ -276,6 +341,29 @@ How the binding is set and respected:
|
|
|
276
341
|
- **Durable round-trip preserves owner.** `/harness export` tags each item with
|
|
277
342
|
`model: provider/id`; `/harness import` restores it. An item whose tag
|
|
278
343
|
pi-reflect stripped becomes an orphan and is adopted by the active model.
|
|
344
|
+
- **Carrying owned items across models: `/harness adopt`.** Orphan adoption
|
|
345
|
+
only covers *unowned* items. `/harness adopt --from <provider/id>` is the
|
|
346
|
+
reviewable path for owned ones: it steers the agent to classify the source
|
|
347
|
+
model's active items **generalizes** (engineering practices, user
|
|
348
|
+
preferences, project facts) vs **stays** (that model's quirks, formatting
|
|
349
|
+
preferences, failure modes), then apply the adoption as ONE `harness_mutate`
|
|
350
|
+
batch of create deltas. It is **copy, not move**: the source model's harness
|
|
351
|
+
stays untouched, content is copied verbatim (importance is listed at full
|
|
352
|
+
precision so the copy keeps its exact ranking), and evidence is annotated
|
|
353
|
+
`adopted from <provider/id>`. Because `ownerModel` is never model-settable,
|
|
354
|
+
the creates stamp to the **active** model via the normal create-stamping
|
|
355
|
+
path — audited and `/tree`-rollback-able like every mutation. `--all` skips
|
|
356
|
+
the filter; overlaps afterwards clean up with `/refine --proposer dedupe`.
|
|
357
|
+
Two store-level filters keep the copy honest (review #25): items scoped to
|
|
358
|
+
a **different project** are skipped (a copy would re-stamp them into this
|
|
359
|
+
session's project — adopt them from a session in that project instead),
|
|
360
|
+
and content the adopter already owns is skipped **before** the batch cap,
|
|
361
|
+
so re-running against a >20-item source reaches new items instead of
|
|
362
|
+
re-selecting the same top ones. The self-adopt guard compares against the
|
|
363
|
+
**resolved binding key** (`virtualBinding`-aware — under `physical` a
|
|
364
|
+
router selection adopts as the routed model), the same key
|
|
365
|
+
create-stamping uses. Explicit and manual only — there is no automatic
|
|
366
|
+
cross-model adoption.
|
|
279
367
|
|
|
280
368
|
Manual commands (`export`, `import`, `move`, `split`, `keep`, `drop`, `prune`,
|
|
281
369
|
`push-mem`, `status`) operate on the **whole store** by design — they are
|
|
@@ -410,10 +498,10 @@ Then `"my-proposer"` is selectable via `/refine --proposer my-proposer` or
|
|
|
410
498
|
auto-import + turn_end layered auto-export when the store changed —
|
|
411
499
|
`/refine` output survives into new sessions with zero ceremony. Off by
|
|
412
500
|
default; every action visible and `/tree`-rollback-able.
|
|
413
|
-
- Optional config (`~/.pi/agent/harness.json`): the durable-sync opt-in,
|
|
414
|
-
|
|
415
|
-
`turn_end` outcome-importance
|
|
416
|
-
paths
|
|
501
|
+
- Optional config (`~/.pi/agent/harness.json`): the durable-sync opt-in, a
|
|
502
|
+
default-on `turn_end` reminder (notify-only; the activation nudge), and the
|
|
503
|
+
opt-in `turn_end` auto-refine and outcome-importance loops. The package's
|
|
504
|
+
autonomous paths stay opt-in and off by default.
|
|
417
505
|
- **Pluggable delta proposers** with a registry: `steering` (default) and
|
|
418
506
|
`dedupe` (rule-based) shipped; `registerProposer()` for custom ones.
|
|
419
507
|
- **Per-model isolation**: every item is bound to a `provider/id` and injected
|
|
@@ -422,7 +510,9 @@ Then `"my-proposer"` is selectable via `/refine --proposer my-proposer` or
|
|
|
422
510
|
round-trip preserves the owner tag. With pi virtual models (routers), the
|
|
423
511
|
`"virtualBinding": "physical"` policy keys items by the routed physical
|
|
424
512
|
model instead of the router id, so a router selection does not silently
|
|
425
|
-
reset the harness.
|
|
513
|
+
reset the harness. Owned items can be carried across models explicitly with
|
|
514
|
+
`/harness adopt --from <provider/id> [--all]` (agent-filtered copy; see
|
|
515
|
+
[Model binding](#model-binding-per-model-isolation)).
|
|
426
516
|
- **Bounded injection (on by default)**: the supplemental block is
|
|
427
517
|
importance-ordered and capped per kind + by a total token budget, so a growing
|
|
428
518
|
harness never balloons the system prompt. Tunable / opt-out via the `injection`
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "pi-continual-harness",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.15.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/auto-refine.ts
CHANGED
|
@@ -25,6 +25,12 @@
|
|
|
25
25
|
// sendUserMessage when the boundary cannot continue (event.context.
|
|
26
26
|
// canContinue false, e.g. after an error turn).
|
|
27
27
|
//
|
|
28
|
+
// Aborted/error turns are inert here: a turn_end whose BoundaryState.outcome
|
|
29
|
+
// is "aborted" or "error" neither seeds nor advances the cadence window, so
|
|
30
|
+
// "every N turns" counts completed turns of work and a refine never fires
|
|
31
|
+
// over partial evidence. (Durable auto-export deliberately still exports on
|
|
32
|
+
// such turns — committed entries must not be lost.)
|
|
33
|
+
//
|
|
28
34
|
// It stays visible: it notifies before firing, and the boundary draft (and the
|
|
29
35
|
// gate decision) appear in the transcript. The decision logic is factored into
|
|
30
36
|
// evaluateAutoRefine() so it is unit-testable without a live pi runtime.
|
|
@@ -34,29 +40,47 @@ import { DEFAULT_AUTO_EVERY_TURNS, type HarnessConfig, loadConfig } from "./conf
|
|
|
34
40
|
import { runRefine, gatherEvidence, DEFAULT_LOOKBACK_TURNS } from "./refine.js";
|
|
35
41
|
import { buildClassify, buildGateRequest, gateDecision } from "./classify.js";
|
|
36
42
|
|
|
37
|
-
//
|
|
38
|
-
|
|
43
|
+
// Completed turns observed since the last fire (or the seeded baseline).
|
|
44
|
+
// -1 = unseen. Aborted/error turns never touch this count — the window is
|
|
45
|
+
// completed turns of work, not raw turn indices.
|
|
46
|
+
let completedTurns = -1;
|
|
39
47
|
|
|
40
48
|
/** Test hook: reset internal counter. */
|
|
41
49
|
export function resetAutoRefine(): void {
|
|
42
|
-
|
|
50
|
+
completedTurns = -1;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* True when a turn_end's outcome marks a turn that ended badly (pi's
|
|
55
|
+
* BoundaryState.outcome: "aborted" | "error"). Undefined is treated as
|
|
56
|
+
* completed — current pi always reports the field, but synthetic events and
|
|
57
|
+
* the earliest 0.99.x may omit it.
|
|
58
|
+
*/
|
|
59
|
+
export function turnEndedBadly(outcome?: string): boolean {
|
|
60
|
+
return outcome === "aborted" || outcome === "error";
|
|
43
61
|
}
|
|
44
62
|
|
|
45
63
|
/**
|
|
46
64
|
* Decide whether to auto-refine this turn. Side-effectful: the first observed
|
|
47
|
-
* turn seeds the baseline (no refine then);
|
|
48
|
-
*
|
|
65
|
+
* completed turn seeds the baseline (no refine then); every completed turn
|
|
66
|
+
* after it advances the count, and each fired refine resets it (which also
|
|
67
|
+
* prevents the refine turn itself from immediately re-triggering).
|
|
68
|
+
* Aborted/error turns are inert: they neither seed nor advance the count —
|
|
69
|
+
* the cadence counts completed turns of work, never raw turn indices, so an
|
|
70
|
+
* aborted turn in between cannot shorten the window.
|
|
49
71
|
*/
|
|
50
|
-
export function evaluateAutoRefine(config: HarnessConfig,
|
|
72
|
+
export function evaluateAutoRefine(config: HarnessConfig, outcome?: string): boolean {
|
|
51
73
|
if (!config.autoRefine?.enabled) return false;
|
|
74
|
+
if (turnEndedBadly(outcome)) return false;
|
|
52
75
|
const every = config.autoRefine.everyTurns ?? DEFAULT_AUTO_EVERY_TURNS;
|
|
53
76
|
if (every <= 0) return false;
|
|
54
|
-
if (
|
|
55
|
-
|
|
77
|
+
if (completedTurns < 0) {
|
|
78
|
+
completedTurns = 0; // seed: the seed turn itself doesn't count
|
|
56
79
|
return false;
|
|
57
80
|
}
|
|
58
|
-
|
|
59
|
-
|
|
81
|
+
completedTurns += 1;
|
|
82
|
+
if (completedTurns >= every) {
|
|
83
|
+
completedTurns = 0;
|
|
60
84
|
return true;
|
|
61
85
|
}
|
|
62
86
|
return false;
|
|
@@ -112,7 +136,7 @@ export function buildOmissionDrafts(projected: Iterable<ProjectedLike>): Omissio
|
|
|
112
136
|
export function registerAutoRefine(pi: ExtensionAPI): void {
|
|
113
137
|
pi.on("turn_end", async (event, ctx) => {
|
|
114
138
|
const config = await loadConfig();
|
|
115
|
-
if (!evaluateAutoRefine(config, event.
|
|
139
|
+
if (!evaluateAutoRefine(config, event.outcome)) return;
|
|
116
140
|
const every = config.autoRefine?.everyTurns ?? DEFAULT_AUTO_EVERY_TURNS;
|
|
117
141
|
|
|
118
142
|
// Opt-in classifier gate: cheap yes/no before spending the refine.
|
package/src/config.ts
CHANGED
|
@@ -6,26 +6,31 @@
|
|
|
6
6
|
// current project's file) and turn_end layered auto-export whenever the
|
|
7
7
|
// live store changed since the last export. Off by default — importing is
|
|
8
8
|
// normally an explicit, reviewable /harness import.
|
|
9
|
-
// -
|
|
10
|
-
// (
|
|
11
|
-
//
|
|
12
|
-
//
|
|
13
|
-
// - remindRefine / autoRefine / outcomeImportance / injection / proposer:
|
|
14
|
-
// unchanged (see below).
|
|
9
|
+
// - remindRefine: the /refine activation nudge (issue #19), ON by default
|
|
10
|
+
// (notify-only, never mutates; `enabled: false` opts back
|
|
11
|
+
// out). autoRefine / outcomeImportance / injection / proposer: unchanged
|
|
12
|
+
// (see below).
|
|
15
13
|
//
|
|
16
14
|
// Robust by design: missing or malformed file → DEFAULT_CONFIG, never throws.
|
|
15
|
+
// Unknown keys are ignored (e.g. the removed pre-0.9.0 `durableScope`).
|
|
17
16
|
|
|
18
|
-
import { readFile } from "node:fs/promises";
|
|
17
|
+
import { copyFile, mkdir, readFile, rename, rm, stat, writeFile } from "node:fs/promises";
|
|
18
|
+
import { randomUUID } from "node:crypto";
|
|
19
19
|
import { homedir } from "node:os";
|
|
20
20
|
import { basename, dirname, join } from "node:path";
|
|
21
21
|
import { DEFAULT_DURABLE_PATH, PROJECT_DURABLE_DIR, type LayerFile, type ScopeInfo } from "./store.js";
|
|
22
22
|
import { DEFAULT_INJECTION, normalizeInjection, type NormalizedInjection } from "./select.js";
|
|
23
23
|
import { DEDUPE_THRESHOLD } from "./proposer.js";
|
|
24
24
|
|
|
25
|
-
export const DEFAULT_EVERY_TURNS =
|
|
25
|
+
export const DEFAULT_EVERY_TURNS = 25;
|
|
26
26
|
export const DEFAULT_AUTO_EVERY_TURNS = 100;
|
|
27
27
|
/** Default per-reference importance bump for the opt-in outcome loop. */
|
|
28
28
|
export const DEFAULT_REF_BUMP = 0.03;
|
|
29
|
+
/** Default cadence (turns) for the auto-import awareness reminder. */
|
|
30
|
+
export const DEFAULT_IMPORT_REMIND_TURNS = 25;
|
|
31
|
+
/** Default escalation cadence for the /refine reminder: every Nth fired
|
|
32
|
+
* reminder is the detailed guide instead of the one-line nudge. */
|
|
33
|
+
export const DEFAULT_DETAIL_EVERY = 4;
|
|
29
34
|
|
|
30
35
|
/** How the binding key treats virtual (router) models. "virtual" (default):
|
|
31
36
|
* key = the selected model's own provider/id. "physical": when the selection
|
|
@@ -43,15 +48,27 @@ export interface NormalizedDedupe {
|
|
|
43
48
|
}
|
|
44
49
|
|
|
45
50
|
export interface HarnessConfig {
|
|
46
|
-
/** DEPRECATED (0.9.0): items carry their own scope now (see /harness move);
|
|
47
|
-
* durable I/O is always layered. Parsed for compatibility, ignored. */
|
|
48
|
-
durableScope?: "global" | "project";
|
|
49
51
|
/** Opt-in durable sync (issue #7): session_start layered auto-import +
|
|
50
52
|
* turn_end layered auto-export when the store changed. Default false. */
|
|
51
53
|
autoImport?: boolean;
|
|
54
|
+
/** Awareness reminders for durable imports (only effective when `autoImport`
|
|
55
|
+
* is on): the session-start import notifies WHAT arrived, and a bounded
|
|
56
|
+
* cadence reminder re-surfaces those items so silently-imported instructions
|
|
57
|
+
* never go unnoticed. `enabled` defaults true; `everyTurns` defaults 25. */
|
|
58
|
+
importRemind?: {
|
|
59
|
+
enabled?: boolean;
|
|
60
|
+
everyTurns?: number;
|
|
61
|
+
};
|
|
62
|
+
/** /refine cadence nudge (ON by default, issue #19 — activation nudge, notify-only):
|
|
63
|
+
* every `everyTurns` completed turns a one-line reminder fires; every
|
|
64
|
+
* `detailEvery`-th reminder escalates to a rotating educational guide
|
|
65
|
+
* (see src/remind.ts). Never mutates state; `enabled: false` restores the
|
|
66
|
+
* old silence. */
|
|
52
67
|
remindRefine?: {
|
|
53
68
|
enabled?: boolean;
|
|
54
69
|
everyTurns?: number;
|
|
70
|
+
/** Every Nth fired reminder is the detailed guide (1 = every reminder). */
|
|
71
|
+
detailEvery?: number;
|
|
55
72
|
};
|
|
56
73
|
autoRefine?: {
|
|
57
74
|
enabled?: boolean;
|
|
@@ -94,9 +111,9 @@ export interface HarnessConfig {
|
|
|
94
111
|
}
|
|
95
112
|
|
|
96
113
|
export const DEFAULT_CONFIG: HarnessConfig = {
|
|
97
|
-
durableScope: "global",
|
|
98
114
|
autoImport: false,
|
|
99
|
-
|
|
115
|
+
importRemind: { enabled: true, everyTurns: DEFAULT_IMPORT_REMIND_TURNS },
|
|
116
|
+
remindRefine: { enabled: true, everyTurns: DEFAULT_EVERY_TURNS, detailEvery: DEFAULT_DETAIL_EVERY },
|
|
100
117
|
autoRefine: { enabled: false, everyTurns: DEFAULT_AUTO_EVERY_TURNS, commit: false, gate: false, omitStaleDrafts: true },
|
|
101
118
|
classifier: { model: undefined },
|
|
102
119
|
virtualBinding: "virtual",
|
|
@@ -119,11 +136,17 @@ let cached: HarnessConfig | undefined;
|
|
|
119
136
|
/** Merge a (possibly partial) parsed file over the defaults. */
|
|
120
137
|
function mergeConfig(over: Partial<HarnessConfig>): HarnessConfig {
|
|
121
138
|
return {
|
|
122
|
-
durableScope: over.durableScope === "project" ? "project" : "global",
|
|
123
139
|
autoImport: over.autoImport === true,
|
|
140
|
+
importRemind: {
|
|
141
|
+
enabled: over.importRemind?.enabled !== false,
|
|
142
|
+
everyTurns: coerceMinTurns(over.importRemind?.everyTurns, DEFAULT_IMPORT_REMIND_TURNS),
|
|
143
|
+
},
|
|
124
144
|
remindRefine: {
|
|
125
|
-
|
|
126
|
-
|
|
145
|
+
// Default-on (issue #19): a notify-only nudge mutates nothing, so the
|
|
146
|
+
// activation lever is pulled unless explicitly declined.
|
|
147
|
+
enabled: over.remindRefine?.enabled !== false,
|
|
148
|
+
everyTurns: coerceMinTurns(over.remindRefine?.everyTurns, DEFAULT_EVERY_TURNS),
|
|
149
|
+
detailEvery: coerceMinTurns(over.remindRefine?.detailEvery, DEFAULT_DETAIL_EVERY),
|
|
127
150
|
},
|
|
128
151
|
autoRefine: {
|
|
129
152
|
enabled: over.autoRefine?.enabled ?? false,
|
|
@@ -152,6 +175,14 @@ function mergeConfig(over: Partial<HarnessConfig>): HarnessConfig {
|
|
|
152
175
|
};
|
|
153
176
|
}
|
|
154
177
|
|
|
178
|
+
/** Coerce a user-provided turn count to a finite integer ≥ 1, else the
|
|
179
|
+
* default. Cadence arithmetic divides/compares against it; 0 or a fraction
|
|
180
|
+
* would fire every turn (spam) and NaN never fires. */
|
|
181
|
+
function coerceMinTurns(raw: unknown, def: number): number {
|
|
182
|
+
if (typeof raw === "number" && Number.isFinite(raw) && Number.isInteger(raw) && raw >= 1) return raw;
|
|
183
|
+
return def;
|
|
184
|
+
}
|
|
185
|
+
|
|
155
186
|
/** Coerce a user-provided bump to a finite number, else the default. The bump
|
|
156
187
|
* is an ARITHMETIC operand (importance + bump), so a non-numeric value must not
|
|
157
188
|
* leak through: importance + "0.03" === "0.50.03" → NaN, which would then be
|
|
@@ -168,6 +199,120 @@ function coerceThreshold(raw: unknown): number {
|
|
|
168
199
|
return typeof raw === "number" && Number.isFinite(raw) && raw > 0 && raw <= 1 ? raw : DEDUPE_THRESHOLD;
|
|
169
200
|
}
|
|
170
201
|
|
|
202
|
+
/** Result of a reviewed config write (see saveConfig). */
|
|
203
|
+
export interface SaveConfigResult {
|
|
204
|
+
/** The file that was written (== `path`). */
|
|
205
|
+
written: string;
|
|
206
|
+
/** Backup of the previous file (`harness.json.bak-<timestamp>`), when one existed. */
|
|
207
|
+
backup: string | undefined;
|
|
208
|
+
/** The exact object serialized to disk (existing raw file ∪ `next`). */
|
|
209
|
+
merged: Record<string, unknown>;
|
|
210
|
+
/** Effective config re-read through loadConfig — what pi will actually load. */
|
|
211
|
+
effective: HarnessConfig;
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
/** True for a non-array plain object (a deep-merge candidate). */
|
|
215
|
+
export function isPlainObject(v: unknown): v is Record<string, unknown> {
|
|
216
|
+
return typeof v === "object" && v !== null && !Array.isArray(v);
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
/** Two-level deep merge (the config shape is two levels deep at most):
|
|
220
|
+
* plain-object keys merge key-by-key so sibling settings survive; everything
|
|
221
|
+
* else is replaced by `next`. Unknown existing keys ride along untouched. */
|
|
222
|
+
export function deepMergeConfig(
|
|
223
|
+
existing: Record<string, unknown>,
|
|
224
|
+
next: Record<string, unknown>,
|
|
225
|
+
): Record<string, unknown> {
|
|
226
|
+
const out: Record<string, unknown> = { ...existing };
|
|
227
|
+
for (const [k, v] of Object.entries(next)) {
|
|
228
|
+
const cur = out[k];
|
|
229
|
+
out[k] = isPlainObject(cur) && isPlainObject(v) ? { ...cur, ...v } : v;
|
|
230
|
+
}
|
|
231
|
+
return out;
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
/** Parse the raw file when it is a JSON object; undefined for missing,
|
|
235
|
+
* malformed, or non-object content. Unknown keys are preserved verbatim
|
|
236
|
+
* (the loader ignores them, a rewrite must not drop them). */
|
|
237
|
+
export async function readConfigRaw(path: string): Promise<Record<string, unknown> | undefined> {
|
|
238
|
+
try {
|
|
239
|
+
const parsed: unknown = JSON.parse(await readFile(path, "utf8"));
|
|
240
|
+
return isPlainObject(parsed) ? parsed : undefined;
|
|
241
|
+
} catch {
|
|
242
|
+
return undefined;
|
|
243
|
+
}
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
/** Filesystem-safe, sortable backup timestamp (colons/dots → dashes). */
|
|
247
|
+
function backupStamp(d = new Date()): string {
|
|
248
|
+
return d.toISOString().replace(/[:.]/g, "-");
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
/** rename with a bounded retry. On Windows, replacing a file that was itself
|
|
252
|
+
* JUST replaced (a concurrent save racing to the same target) can transiently
|
|
253
|
+
* fail with EPERM/EACCES/ENOTEMPTY while the previous replacement settles
|
|
254
|
+
* into the delete-pending state. A short backoff clears it; every other
|
|
255
|
+
* error (and errors that persist) propagates. */
|
|
256
|
+
async function renameRetry(tmp: string, path: string, tries = 3): Promise<void> {
|
|
257
|
+
for (let attempt = 0; ; attempt += 1) {
|
|
258
|
+
try {
|
|
259
|
+
await rename(tmp, path);
|
|
260
|
+
return;
|
|
261
|
+
} catch (err) {
|
|
262
|
+
const code = (err as NodeJS.ErrnoException).code;
|
|
263
|
+
if (attempt < tries && (code === "EPERM" || code === "EACCES" || code === "ENOTEMPTY")) {
|
|
264
|
+
await new Promise((resolve) => setTimeout(resolve, 10 * (attempt + 1)));
|
|
265
|
+
continue;
|
|
266
|
+
}
|
|
267
|
+
throw err;
|
|
268
|
+
}
|
|
269
|
+
}
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
/** The ONE reviewed write path for harness.json (used by /harness setup):
|
|
273
|
+
* merge `next` over the existing RAW file (two levels deep, unknown keys
|
|
274
|
+
* preserved), back the previous file up as `harness.json.bak-<timestamp>`,
|
|
275
|
+
* atomically write (same-dir tmp + rename), then drop the config cache and
|
|
276
|
+
* reload — the returned `effective` round-trips through the same
|
|
277
|
+
* loader/coercion path pi uses, so what the wizard displayed is what pi
|
|
278
|
+
* reads. Throws BEFORE any write when the backup cannot be made: the
|
|
279
|
+
* previous file is never destroyed without one. */
|
|
280
|
+
export async function saveConfig(
|
|
281
|
+
next: Partial<HarnessConfig> | Record<string, unknown>,
|
|
282
|
+
path: string = CONFIG_PATH,
|
|
283
|
+
): Promise<SaveConfigResult> {
|
|
284
|
+
const existing = await readConfigRaw(path);
|
|
285
|
+
const merged = deepMergeConfig(existing ?? {}, next as Record<string, unknown>);
|
|
286
|
+
const json = `${JSON.stringify(merged, null, 2)}\n`;
|
|
287
|
+
// Back up by FILE EXISTENCE, not parse success: a malformed file must be
|
|
288
|
+
// preserved too (it may hold recoverable content; the rewrite replaces it).
|
|
289
|
+
let backup: string | undefined;
|
|
290
|
+
try {
|
|
291
|
+
await stat(path);
|
|
292
|
+
backup = `${path}.bak-${backupStamp()}`;
|
|
293
|
+
await copyFile(path, backup);
|
|
294
|
+
} catch (err) {
|
|
295
|
+
if ((err as NodeJS.ErrnoException).code !== "ENOENT") throw err; // backup failure → abort, no write
|
|
296
|
+
}
|
|
297
|
+
await mkdir(dirname(path), { recursive: true }); // first run: ~/.pi/agent may not exist yet
|
|
298
|
+
// Unique + exclusive temp name: two concurrent saveConfig calls (two pi
|
|
299
|
+
// sessions, or two wizards) must never share a tmp file — the loser would
|
|
300
|
+
// truncate the winner's content or rename it away mid-write. "wx" fails
|
|
301
|
+
// loudly on collision instead of corrupting (a UUID collision is already
|
|
302
|
+
// theoretical; this makes it impossible).
|
|
303
|
+
const tmp = `${path}.tmp-${randomUUID().slice(0, 8)}`;
|
|
304
|
+
try {
|
|
305
|
+
await writeFile(tmp, json, { encoding: "utf8", flag: "wx" });
|
|
306
|
+
await renameRetry(tmp, path); // atomic on POSIX; REPLACE_EXISTING on Windows
|
|
307
|
+
} catch (err) {
|
|
308
|
+
await rm(tmp, { force: true }).catch(() => {}); // best-effort tmp cleanup
|
|
309
|
+
throw err;
|
|
310
|
+
}
|
|
311
|
+
resetConfigCache();
|
|
312
|
+
const effective = await loadConfig(path);
|
|
313
|
+
return { written: path, backup, merged, effective };
|
|
314
|
+
}
|
|
315
|
+
|
|
171
316
|
/** Load the config, merged over defaults. Tolerant: missing/malformed → defaults.
|
|
172
317
|
* Cached for the process lifetime (config is read once per session). */
|
|
173
318
|
export async function loadConfig(path: string = CONFIG_PATH): Promise<HarnessConfig> {
|
|
@@ -186,15 +331,6 @@ export function resetConfigCache(): void {
|
|
|
186
331
|
cached = undefined;
|
|
187
332
|
}
|
|
188
333
|
|
|
189
|
-
/**
|
|
190
|
-
* @deprecated Durable I/O is layered on per-item scope (0.9.0); this key no
|
|
191
|
-
* longer switches the durable path. Kept so old configs keep loading.
|
|
192
|
-
*/
|
|
193
|
-
export function resolveDurablePath(config: HarnessConfig, cwd?: string): string {
|
|
194
|
-
if (config.durableScope !== "project") return DEFAULT_DURABLE_PATH;
|
|
195
|
-
return projectDurablePath(cwd);
|
|
196
|
-
}
|
|
197
|
-
|
|
198
334
|
/** Path of a project's durable layer file: <projectDir>/<slug>.md. */
|
|
199
335
|
export function projectDurablePath(cwd?: string): string {
|
|
200
336
|
return join(PROJECT_DURABLE_DIR, `${projectSlug(cwd)}.md`);
|
package/src/corpus.ts
CHANGED
|
@@ -135,38 +135,58 @@ function durableLayer(i: HarnessItem): string {
|
|
|
135
135
|
|
|
136
136
|
// ---- lifecycle ---------------------------------------------------------------
|
|
137
137
|
|
|
138
|
-
/**
|
|
138
|
+
/** One classified lifecycle transition, with the item it happened to.
|
|
139
|
+
* Exported so the status report (src/status.ts) can aggregate events with
|
|
140
|
+
* owner-model attribution; `buildCorpus` maps these onto contract records. */
|
|
141
|
+
export interface LifecycleTransition {
|
|
142
|
+
event: LifecycleEvent;
|
|
143
|
+
/** Post-transition item (for removals: the removed item as last seen). */
|
|
144
|
+
item: HarnessItem;
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
/** Classify one snapshot → next-snapshot transition into lifecycle events.
|
|
139
148
|
* `citeBump` is the CONFIGURED outcome-loop bump (see CorpusOptions). */
|
|
140
|
-
function
|
|
149
|
+
export function classifyTransition(
|
|
141
150
|
prev: HarnessState,
|
|
142
151
|
next: HarnessState,
|
|
143
152
|
citeBump: number,
|
|
144
|
-
|
|
145
|
-
|
|
153
|
+
): LifecycleTransition[] {
|
|
154
|
+
const out: LifecycleTransition[] = [];
|
|
146
155
|
const prevById = new Map(prev.items.map((i) => [i.id, i]));
|
|
147
156
|
const nextById = new Map(next.items.map((i) => [i.id, i]));
|
|
148
157
|
|
|
149
158
|
for (const item of next.items) {
|
|
150
159
|
const before = prevById.get(item.id);
|
|
151
160
|
if (!before) {
|
|
152
|
-
out.push(
|
|
161
|
+
out.push({ event: "created", item });
|
|
153
162
|
continue;
|
|
154
163
|
}
|
|
155
164
|
const d = item.importance - before.importance;
|
|
156
165
|
if (Math.abs(d) < EPSILON) continue;
|
|
157
166
|
// keep/drop FIRST: a configured citation bump of exactly ±0.1 collides and
|
|
158
167
|
// keep/drop win (documented in CorpusOptions).
|
|
159
|
-
if (Math.abs(d - KEEP_DROP_BUMP) <= BUMP_TOL) out.push(
|
|
160
|
-
else if (Math.abs(d + KEEP_DROP_BUMP) <= BUMP_TOL)
|
|
161
|
-
out.push(lifecycle(item, "dropped", item.importance));
|
|
168
|
+
if (Math.abs(d - KEEP_DROP_BUMP) <= BUMP_TOL) out.push({ event: "kept", item });
|
|
169
|
+
else if (Math.abs(d + KEEP_DROP_BUMP) <= BUMP_TOL) out.push({ event: "dropped", item });
|
|
162
170
|
else if (citeBump > 0 && Math.abs(d - citeBump) <= BUMP_TOL)
|
|
163
|
-
out.push(
|
|
171
|
+
out.push({ event: "cited", item });
|
|
164
172
|
// else: explicit harness_mutate importance set — not a lifecycle signal.
|
|
165
173
|
}
|
|
166
174
|
for (const item of prev.items) {
|
|
167
175
|
if (nextById.has(item.id)) continue;
|
|
168
176
|
const event = item.importance < IMPORTANCE_FLOOR ? "pruned" : "deleted";
|
|
169
|
-
out.push(
|
|
177
|
+
out.push({ event, item });
|
|
178
|
+
}
|
|
179
|
+
return out;
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
function diffSnapshots(
|
|
183
|
+
prev: HarnessState,
|
|
184
|
+
next: HarnessState,
|
|
185
|
+
citeBump: number,
|
|
186
|
+
out: LifecycleRecord[],
|
|
187
|
+
): void {
|
|
188
|
+
for (const t of classifyTransition(prev, next, citeBump)) {
|
|
189
|
+
out.push(lifecycle(t.item, t.event, t.item.importance));
|
|
170
190
|
}
|
|
171
191
|
}
|
|
172
192
|
|