pi-continual-harness 0.8.1 → 0.10.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 +92 -29
- package/package.json +1 -1
- package/src/config.ts +82 -13
- package/src/durable.ts +88 -0
- package/src/harness.ts +199 -50
- package/src/index.ts +21 -4
- package/src/proposer.ts +154 -40
- package/src/refine.ts +47 -10
- package/src/store.ts +268 -30
- package/src/tools.ts +12 -0
- package/src/types.ts +21 -0
package/README.md
CHANGED
|
@@ -13,10 +13,12 @@ with:
|
|
|
13
13
|
→ behavioral-file refinement (the "deep" path).
|
|
14
14
|
- **pi-mem / pi-memory** — durable memory storage.
|
|
15
15
|
|
|
16
|
-
The durable markdown
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
16
|
+
The durable markdown layers are the composition seam — the shared
|
|
17
|
+
`~/.pi/agent/harness-state.md` plus per-project files under
|
|
18
|
+
`~/.pi/agent/harness-state/<slug>.md` — and they are **two-way**:
|
|
19
|
+
`/refine --commit` and `/harness export` write them; `/harness import` parses
|
|
20
|
+
them back and merges into the live store (offline edits win on conflict), so
|
|
21
|
+
refinements pi-reflect makes flow back online. `/harness push-mem`
|
|
20
22
|
pushes active items into pi-mem's semantic store (see
|
|
21
23
|
[Composing with pi-mem](#composing-with-pi-mem)).
|
|
22
24
|
|
|
@@ -51,33 +53,50 @@ Or drop `src/index.ts` into `~/.pi/agent/extensions/`.
|
|
|
51
53
|
```
|
|
52
54
|
/refine # review last 25 turns, propose deltas
|
|
53
55
|
/refine 50 # review last 50 turns
|
|
54
|
-
/refine 25 --commit # also export durable state
|
|
56
|
+
/refine 25 --commit # also export durable state (global + project layers)
|
|
55
57
|
/refine --proposer dedupe # run the rule-based dedupe proposer instead of steering
|
|
58
|
+
/refine --proposer dedupe --threshold 0.75 # dedupe with a one-shot threshold
|
|
56
59
|
```
|
|
57
60
|
|
|
58
61
|
Durable I/O (round-trip with pi-reflect):
|
|
59
62
|
|
|
60
63
|
```
|
|
61
|
-
/harness status # counts + durable
|
|
62
|
-
/harness export [path] #
|
|
63
|
-
/harness import [--prune] [path] #
|
|
64
|
+
/harness status # counts + durable layer presence/mtime
|
|
65
|
+
/harness export [path] # layered export (or full snapshot to a path)
|
|
66
|
+
/harness import [--prune] [path] # layered import (or single file from a path)
|
|
64
67
|
/harness prune [--decay <days>] # drop items below the importance floor
|
|
65
68
|
/harness keep <id> # nudge importance up (+0.1)
|
|
66
69
|
/harness drop <id> # nudge importance down (−0.1)
|
|
70
|
+
/harness move <id> global|project # move an item between durable layers
|
|
71
|
+
/harness split # steer the agent to classify every item's scope
|
|
67
72
|
/harness push-mem [--all|--kind <kind>|--model <provider/id|active>] # persist active items to pi-mem (save_memory)
|
|
68
73
|
```
|
|
69
74
|
|
|
70
|
-
Subcommands, flags, `--kind`/`--model` values,
|
|
71
|
-
autocomplete in the TUI as you type after
|
|
72
|
-
|
|
73
|
-
|
|
75
|
+
Subcommands, flags, `--kind`/`--model` values, `move` scopes, and `keep`/
|
|
76
|
+
`drop`/`move` item ids all autocomplete in the TUI as you type after
|
|
77
|
+
`/harness `.
|
|
78
|
+
|
|
79
|
+
**Durable layers.** Every item carries its own durable scope: `global`
|
|
80
|
+
(the shared `~/.pi/agent/harness-state.md`) or `project`
|
|
81
|
+
(`~/.pi/agent/harness-state/<slug>.md`, slug derived from the session `cwd`).
|
|
82
|
+
`/harness export` without a path partitions items by their scope into the
|
|
83
|
+
layer files; `/harness import` without a path merges the global file always,
|
|
84
|
+
plus the current project's file (the project layer wins id collisions;
|
|
85
|
+
`--prune` drops only items absent from **every** layer). An explicit path keeps
|
|
86
|
+
the classic single-file semantics. Move items between layers with
|
|
87
|
+
`/harness move`, or classify the whole store at once with `/harness split`
|
|
88
|
+
(the agent proposes scope-only `harness_mutate` updates — visible in the
|
|
89
|
+
transcript, `/tree`-rollback-able).
|
|
90
|
+
|
|
91
|
+
`import` reconciles the file(s) into the live store: items whose id matches an
|
|
74
92
|
existing entry are updated (offline edits win on content/evidence/importance),
|
|
75
93
|
new entries are created. By default nothing is deleted — `--prune` also drops
|
|
76
|
-
active items whose id is no longer in the file (inactive items are always
|
|
77
|
-
preserved). Point pi-reflect at
|
|
94
|
+
active items whose id is no longer in the file(s) (inactive items are always
|
|
95
|
+
preserved). Point pi-reflect at either layer to refine it offline:
|
|
78
96
|
|
|
79
97
|
```
|
|
80
98
|
/reflect ~/.pi/agent/harness-state.md
|
|
99
|
+
/reflect ~/.pi/agent/harness-state/<project-slug>.md
|
|
81
100
|
```
|
|
82
101
|
|
|
83
102
|
The model-facing tools:
|
|
@@ -88,6 +107,9 @@ The model-facing tools:
|
|
|
88
107
|
- `harness_mutate { deltas: [...] }` — apply a batch of `create` / `update` /
|
|
89
108
|
`delete` deltas. Every `create` requires `evidence`. New items are stamped
|
|
90
109
|
automatically with the active model (see [Model binding](#model-binding-per-model-isolation)).
|
|
110
|
+
`create`/`update` deltas also accept `scope: "global" | "project"` (the
|
|
111
|
+
project slug is stamped server-side from the session cwd — used by
|
|
112
|
+
`/harness split`; normally managed via `/harness move`).
|
|
91
113
|
|
|
92
114
|
Active items are injected into the system prompt each turn as a structured
|
|
93
115
|
block, appended to (never replacing) the base prompt.
|
|
@@ -117,8 +139,9 @@ Optional config at `~/.pi/agent/harness.json` (missing or malformed → defaults
|
|
|
117
139
|
|
|
118
140
|
```json
|
|
119
141
|
{
|
|
120
|
-
"
|
|
142
|
+
"autoImport": false,
|
|
121
143
|
"proposer": "steering",
|
|
144
|
+
"dedupe": { "threshold": 0.6, "merge": true },
|
|
122
145
|
"injection": { "enabled": true, "maxTokens": 1500, "maxPerKind": 10, "charsPerToken": 4 },
|
|
123
146
|
"remindRefine": { "enabled": false, "everyTurns": 50 },
|
|
124
147
|
"autoRefine": { "enabled": false, "everyTurns": 100, "commit": false },
|
|
@@ -126,10 +149,15 @@ Optional config at `~/.pi/agent/harness.json` (missing or malformed → defaults
|
|
|
126
149
|
}
|
|
127
150
|
```
|
|
128
151
|
|
|
129
|
-
- **`
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
152
|
+
- **`autoImport`** — opt-in **durable sync** (off by default). `true` bundles
|
|
153
|
+
both directions: on `session_start` the durable layers are imported
|
|
154
|
+
automatically (global file always + the current project's file, with the same
|
|
155
|
+
loss-free merge as `/harness import` — an import that changes nothing stays
|
|
156
|
+
silent), and on `turn_end` the layers are re-exported whenever the live store
|
|
157
|
+
changed since the last export. This is what makes `/refine` output survive
|
|
158
|
+
into **new** sessions without manual export/import ceremony. Both halves are
|
|
159
|
+
visible (one notify line) and use the same persisted `harness-state` entries
|
|
160
|
+
as every other mutation (`/tree` rollback covers them).
|
|
133
161
|
- **`injection`** — the selection policy for WHAT gets surfaced in the system
|
|
134
162
|
prompt each turn (see [Injection selection](#injection-selection-on-by-default)).
|
|
135
163
|
ON by default: items are importance-ordered, capped at `maxPerKind` (default
|
|
@@ -137,7 +165,19 @@ Optional config at `~/.pi/agent/harness.json` (missing or malformed → defaults
|
|
|
137
165
|
restore the legacy "inject all items, in store order" behaviour.
|
|
138
166
|
- **`proposer`** — which delta proposer `/refine` and auto-refine use. Defaults
|
|
139
167
|
to `steering` (the agent reasons via a steering message). `dedupe` applies a
|
|
140
|
-
rule-based
|
|
168
|
+
rule-based merge directly. See [Proposers](#proposers).
|
|
169
|
+
- **`dedupe`** — the rule-based dedupe proposer's policy. `threshold` (default
|
|
170
|
+
`0.6`, valid `(0,1]`) is the token-overlap level at which two items sharing
|
|
171
|
+
the key fields (kind, owner model, durable layer) count as duplicates;
|
|
172
|
+
`merge` (default `true`) **merges** each duplicate into its keeper — the
|
|
173
|
+
keeper keeps its content verbatim, its `evidence` becomes the line-wise union
|
|
174
|
+
of both, applied as one audited `update` + `delete` pair — while `false`
|
|
175
|
+
restores the pre-0.10 delete-only behavior. `/refine --proposer dedupe
|
|
176
|
+
--threshold 0.75` overrides the threshold for one run. A fourth key,
|
|
177
|
+
`similarity`, is API-only (functions cannot come from `harness.json`):
|
|
178
|
+
companion packages inject a richer comparator whose return may **abstain**
|
|
179
|
+
per pair (`{ score, abstain }` — see [`SimilarityResult`](#proposers)); an
|
|
180
|
+
abstaining pair is always kept, never merged or deleted.
|
|
141
181
|
- **`remindRefine`** — opt-in `turn_end` nudge. `{ "enabled": true,
|
|
142
182
|
"everyTurns": 50 }` notifies you to run `/refine` on a cadence. It is
|
|
143
183
|
informational only — it never mutates state.
|
|
@@ -157,6 +197,13 @@ Optional config at `~/.pi/agent/harness.json` (missing or malformed → defaults
|
|
|
157
197
|
outcomes is intentionally NOT done (high false-positive); use `/harness drop`,
|
|
158
198
|
`prune --decay`, or the `dedupe` proposer for that.
|
|
159
199
|
|
|
200
|
+
> **Deprecated:** `durableScope` (0.9.0). Items now carry their own scope
|
|
201
|
+
> (`/harness move`), and durable I/O is always layered — the key no longer
|
|
202
|
+
> switches anything but is still parsed so existing configs keep loading.
|
|
203
|
+
> Migration from a `durableScope: "project"` setup: run `/harness import` once
|
|
204
|
+
> in the project (its file's items adopt project scope), then move any strays
|
|
205
|
+
> with `/harness move` (or classify the whole store with `/harness split`).
|
|
206
|
+
|
|
160
207
|
## How it works
|
|
161
208
|
|
|
162
209
|
1. `/refine` gathers recent trajectory evidence from the current session branch.
|
|
@@ -205,9 +252,9 @@ How the binding is set and respected:
|
|
|
205
252
|
`model: provider/id`; `/harness import` restores it. An item whose tag
|
|
206
253
|
pi-reflect stripped becomes an orphan and is adopted by the active model.
|
|
207
254
|
|
|
208
|
-
Manual commands (`export`, `import`, `keep`, `drop`, `prune`,
|
|
209
|
-
`status`) operate on the **whole store** by design — they are
|
|
210
|
-
actions with full control. Isolation is enforced only where pollution would
|
|
255
|
+
Manual commands (`export`, `import`, `move`, `split`, `keep`, `drop`, `prune`,
|
|
256
|
+
`push-mem`, `status`) operate on the **whole store** by design — they are
|
|
257
|
+
explicit human actions with full control. Isolation is enforced only where pollution would
|
|
211
258
|
leak automatically: injection, listing, create-stamping, and the outcome loop.
|
|
212
259
|
In particular, `/harness push-mem` pushes *every* model's active items into
|
|
213
260
|
pi-mem by default (which can yield near-duplicate memories across models); pass
|
|
@@ -264,7 +311,16 @@ deltas directly). The propose stage is pluggable via a registry
|
|
|
264
311
|
| Name | What it does |
|
|
265
312
|
|---|---|
|
|
266
313
|
| `steering` (default) | Delegates reasoning to the agent via a steering message — reuses the agent loop, model-agnostic, fully visible. |
|
|
267
|
-
| `dedupe` | Rule-based:
|
|
314
|
+
| `dedupe` | Rule-based: **merges** near-duplicate active items (token-overlap ≥ the configured threshold, same kind / owner model / durable layer) into the higher-importance keeper — one evidence-union update per keeper, then deletes the duplicates. `"dedupe": { "merge": false }` restores delete-only. No model call. |
|
|
315
|
+
|
|
316
|
+
The dedupe planner's `similarity` seam (`DedupeOptions.similarity`, re-exported
|
|
317
|
+
from the package entry) is how a companion package upgrades the comparison —
|
|
318
|
+
e.g. embedding cosine similarity, or a local decision engine such as pi-jev
|
|
319
|
+
(a local-only project for now). A comparator may return
|
|
320
|
+
`{ score, abstain }` instead of a plain number: **an abstaining pair is
|
|
321
|
+
treated as not duplicates** — both items are kept — so an engine with
|
|
322
|
+
conformal uncertainty guarantees can safely decline to merge. Plain numeric
|
|
323
|
+
returns keep working (the default remains token Jaccard).
|
|
268
324
|
|
|
269
325
|
Select a proposer per run with `/refine --proposer <name>`, or set the default
|
|
270
326
|
for auto-refine via `proposer` in the [config](#configuration). Both paths are
|
|
@@ -313,19 +369,26 @@ Then `"my-proposer"` is selectable via `/refine --proposer my-proposer` or
|
|
|
313
369
|
|
|
314
370
|
## Status
|
|
315
371
|
|
|
316
|
-
0.
|
|
372
|
+
0.9.x. Implemented:
|
|
317
373
|
|
|
318
374
|
- Unified harness-state store with branch-local snapshots (`/tree` rollback).
|
|
319
375
|
- Online `/refine` + `harness_mutate` / `harness_list` tools.
|
|
320
|
-
- Two-way durable round-trip with pi-reflect (`/harness import|export|status`)
|
|
376
|
+
- Two-way durable round-trip with pi-reflect (`/harness import|export|status`),
|
|
377
|
+
**layered on per-item scope**: every item is `global` or `project`
|
|
378
|
+
(`/harness move`, `/harness split`), and `/harness export|import` operate on
|
|
379
|
+
both layers.
|
|
321
380
|
- Importance hygiene: `/harness prune [--decay <days>]` and `/harness keep|drop
|
|
322
381
|
<id>`.
|
|
323
382
|
- **pi-mem composition**: `/harness push-mem [--all|--kind|--model]` steers the agent
|
|
324
383
|
to persist active items into pi-mem (soft-fail; no dependency).
|
|
325
|
-
-
|
|
384
|
+
- **Opt-in durable sync** (`"autoImport": true`): session_start layered
|
|
385
|
+
auto-import + turn_end layered auto-export when the store changed —
|
|
386
|
+
`/refine` output survives into new sessions with zero ceremony. Off by
|
|
387
|
+
default; every action visible and `/tree`-rollback-able.
|
|
388
|
+
- Optional config (`~/.pi/agent/harness.json`): the durable-sync opt-in, an
|
|
326
389
|
opt-in `turn_end` reminder, opt-in `turn_end` auto-refine, and an opt-in
|
|
327
|
-
`turn_end` outcome-importance loop — the package's
|
|
328
|
-
paths (
|
|
390
|
+
`turn_end` outcome-importance loop — the package's opt-in autonomous
|
|
391
|
+
paths (all off by default).
|
|
329
392
|
- **Pluggable delta proposers** with a registry: `steering` (default) and
|
|
330
393
|
`dedupe` (rule-based) shipped; `registerProposer()` for custom ones.
|
|
331
394
|
- **Per-model isolation**: every item is bound to a `provider/id` and injected
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "pi-continual-harness",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.10.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
|
@@ -1,28 +1,48 @@
|
|
|
1
1
|
// User configuration for continual-harness: ~/.pi/agent/harness.json
|
|
2
2
|
//
|
|
3
|
-
//
|
|
4
|
-
// -
|
|
5
|
-
//
|
|
6
|
-
//
|
|
7
|
-
//
|
|
8
|
-
//
|
|
9
|
-
//
|
|
3
|
+
// This file (optional; missing → defaults) configures:
|
|
4
|
+
// - autoImport: opt-in durable sync (issue #7). `true` bundles BOTH
|
|
5
|
+
// directions: session_start layered auto-import (global file always + the
|
|
6
|
+
// current project's file) and turn_end layered auto-export whenever the
|
|
7
|
+
// live store changed since the last export. Off by default — importing is
|
|
8
|
+
// normally an explicit, reviewable /harness import.
|
|
9
|
+
// - durableScope: DEPRECATED (0.9.0). Items now carry their own scope
|
|
10
|
+
// (`/harness move <id> global|project`); durable I/O is layered on top of
|
|
11
|
+
// per-item scope and this key no longer switches anything. Still parsed so
|
|
12
|
+
// existing configs keep loading; will be removed in a future release.
|
|
13
|
+
// - remindRefine / autoRefine / outcomeImportance / injection / proposer:
|
|
14
|
+
// unchanged (see below).
|
|
10
15
|
//
|
|
11
16
|
// Robust by design: missing or malformed file → DEFAULT_CONFIG, never throws.
|
|
12
17
|
|
|
13
18
|
import { readFile } from "node:fs/promises";
|
|
14
19
|
import { homedir } from "node:os";
|
|
15
|
-
import { join } from "node:path";
|
|
16
|
-
import { DEFAULT_DURABLE_PATH } from "./store.js";
|
|
20
|
+
import { basename, dirname, join } from "node:path";
|
|
21
|
+
import { DEFAULT_DURABLE_PATH, PROJECT_DURABLE_DIR, type LayerFile, type ScopeInfo } from "./store.js";
|
|
17
22
|
import { DEFAULT_INJECTION, normalizeInjection, type NormalizedInjection } from "./select.js";
|
|
23
|
+
import { DEDUPE_THRESHOLD } from "./proposer.js";
|
|
18
24
|
|
|
19
25
|
export const DEFAULT_EVERY_TURNS = 50;
|
|
20
26
|
export const DEFAULT_AUTO_EVERY_TURNS = 100;
|
|
21
27
|
/** Default per-reference importance bump for the opt-in outcome loop. */
|
|
22
28
|
export const DEFAULT_REF_BUMP = 0.03;
|
|
23
29
|
|
|
30
|
+
/** Resolved dedupe policy (merge-capable, 0.10.0). Populated by loadConfig. */
|
|
31
|
+
export interface NormalizedDedupe {
|
|
32
|
+
/** Token-overlap threshold in (0,1]; pairs at/above it merge. Default 0.6. */
|
|
33
|
+
threshold: number;
|
|
34
|
+
/** Merge duplicates into the keeper (evidence union) instead of delete-only.
|
|
35
|
+
* Default true; `false` restores the pre-0.10 delete-only behavior. */
|
|
36
|
+
merge: boolean;
|
|
37
|
+
}
|
|
38
|
+
|
|
24
39
|
export interface HarnessConfig {
|
|
40
|
+
/** DEPRECATED (0.9.0): items carry their own scope now (see /harness move);
|
|
41
|
+
* durable I/O is always layered. Parsed for compatibility, ignored. */
|
|
25
42
|
durableScope?: "global" | "project";
|
|
43
|
+
/** Opt-in durable sync (issue #7): session_start layered auto-import +
|
|
44
|
+
* turn_end layered auto-export when the store changed. Default false. */
|
|
45
|
+
autoImport?: boolean;
|
|
26
46
|
remindRefine?: {
|
|
27
47
|
enabled?: boolean;
|
|
28
48
|
everyTurns?: number;
|
|
@@ -43,14 +63,22 @@ export interface HarnessConfig {
|
|
|
43
63
|
/** Injection selection policy (on by default). Resolved by loadConfig, so the
|
|
44
64
|
* value here is always fully-populated. See src/select.ts. */
|
|
45
65
|
injection?: NormalizedInjection;
|
|
66
|
+
/** Dedupe proposer policy (merge-capable). Resolved by loadConfig, so the
|
|
67
|
+
* value here is always fully-populated. See src/proposer.ts. */
|
|
68
|
+
dedupe?: NormalizedDedupe;
|
|
46
69
|
}
|
|
47
70
|
|
|
48
71
|
export const DEFAULT_CONFIG: HarnessConfig = {
|
|
49
72
|
durableScope: "global",
|
|
73
|
+
autoImport: false,
|
|
50
74
|
remindRefine: { enabled: false, everyTurns: DEFAULT_EVERY_TURNS },
|
|
51
75
|
autoRefine: { enabled: false, everyTurns: DEFAULT_AUTO_EVERY_TURNS, commit: false },
|
|
52
76
|
proposer: "steering",
|
|
53
77
|
outcomeImportance: { enabled: false, bump: DEFAULT_REF_BUMP },
|
|
78
|
+
// Dedupe (merge-capable): merge ON at the historical 0.6 threshold — a
|
|
79
|
+
// near-duplicate merges into its keeper (evidence unioned) instead of being
|
|
80
|
+
// deleted cold. `merge: false` restores delete-only.
|
|
81
|
+
dedupe: { threshold: DEDUPE_THRESHOLD, merge: true },
|
|
54
82
|
// ON by default: importance-ordered, maxPerKind 10, maxTokens 1500. A no-op
|
|
55
83
|
// for small stores; protective as the harness accumulates. Opt out with
|
|
56
84
|
// `injection.enabled: false`. See src/select.ts.
|
|
@@ -65,6 +93,7 @@ let cached: HarnessConfig | undefined;
|
|
|
65
93
|
function mergeConfig(over: Partial<HarnessConfig>): HarnessConfig {
|
|
66
94
|
return {
|
|
67
95
|
durableScope: over.durableScope === "project" ? "project" : "global",
|
|
96
|
+
autoImport: over.autoImport === true,
|
|
68
97
|
remindRefine: {
|
|
69
98
|
enabled: over.remindRefine?.enabled ?? false,
|
|
70
99
|
everyTurns: over.remindRefine?.everyTurns ?? DEFAULT_EVERY_TURNS,
|
|
@@ -83,6 +112,10 @@ function mergeConfig(over: Partial<HarnessConfig>): HarnessConfig {
|
|
|
83
112
|
// malformed `injection` object degrades to the shipped defaults rather than
|
|
84
113
|
// corrupting the block sizing arithmetic.
|
|
85
114
|
injection: normalizeInjection(over.injection as Partial<{ enabled: boolean; maxTokens: number; maxPerKind: number; charsPerToken: number }> | undefined),
|
|
115
|
+
dedupe: {
|
|
116
|
+
threshold: coerceThreshold(over.dedupe?.threshold),
|
|
117
|
+
merge: over.dedupe?.merge !== false,
|
|
118
|
+
},
|
|
86
119
|
};
|
|
87
120
|
}
|
|
88
121
|
|
|
@@ -94,6 +127,14 @@ function coerceBump(raw: unknown): number {
|
|
|
94
127
|
return typeof raw === "number" && Number.isFinite(raw) ? raw : DEFAULT_REF_BUMP;
|
|
95
128
|
}
|
|
96
129
|
|
|
130
|
+
/** Coerce a user-provided dedupe threshold to a finite number in (0,1], else
|
|
131
|
+
* the default. The threshold is a COMPARISON operand (similarity >= threshold);
|
|
132
|
+
* out-of-range or non-numeric values must degrade to the default rather than
|
|
133
|
+
* silently matching everything (0/-1) or nothing (>1). */
|
|
134
|
+
function coerceThreshold(raw: unknown): number {
|
|
135
|
+
return typeof raw === "number" && Number.isFinite(raw) && raw > 0 && raw <= 1 ? raw : DEDUPE_THRESHOLD;
|
|
136
|
+
}
|
|
137
|
+
|
|
97
138
|
/** Load the config, merged over defaults. Tolerant: missing/malformed → defaults.
|
|
98
139
|
* Cached for the process lifetime (config is read once per session). */
|
|
99
140
|
export async function loadConfig(path: string = CONFIG_PATH): Promise<HarnessConfig> {
|
|
@@ -113,13 +154,41 @@ export function resetConfigCache(): void {
|
|
|
113
154
|
}
|
|
114
155
|
|
|
115
156
|
/**
|
|
116
|
-
*
|
|
117
|
-
*
|
|
118
|
-
* - project → ~/.pi/agent/harness-state/<slug>.md (slug derived from cwd)
|
|
157
|
+
* @deprecated Durable I/O is layered on per-item scope (0.9.0); this key no
|
|
158
|
+
* longer switches the durable path. Kept so old configs keep loading.
|
|
119
159
|
*/
|
|
120
160
|
export function resolveDurablePath(config: HarnessConfig, cwd?: string): string {
|
|
121
161
|
if (config.durableScope !== "project") return DEFAULT_DURABLE_PATH;
|
|
122
|
-
return
|
|
162
|
+
return projectDurablePath(cwd);
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
/** Path of a project's durable layer file: <projectDir>/<slug>.md. */
|
|
166
|
+
export function projectDurablePath(cwd?: string): string {
|
|
167
|
+
return join(PROJECT_DURABLE_DIR, `${projectSlug(cwd)}.md`);
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
/** The two layers a layered import reads for a session in `cwd`: global first
|
|
171
|
+
* (so the project layer wins id collisions), the project layer only when its
|
|
172
|
+
* slug matches the session cwd. Scope defaults for untagged items ride along
|
|
173
|
+
* (a layer file's items belong to that layer). */
|
|
174
|
+
export function layerFilesFor(cwd?: string): LayerFile[] {
|
|
175
|
+
return [
|
|
176
|
+
{ path: DEFAULT_DURABLE_PATH, defaultScope: { scope: "global" } },
|
|
177
|
+
{ path: projectDurablePath(cwd), defaultScope: { scope: "project", project: projectSlug(cwd) } },
|
|
178
|
+
];
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
/** Derive the layer scope a file at `path` represents — used when a user
|
|
182
|
+
* imports a file by explicit path: the global file → global; anything under
|
|
183
|
+
* the project dir → project + slug from the filename stem; other paths →
|
|
184
|
+
* global. Pure path-layout knowledge. */
|
|
185
|
+
export function defaultScopeForPath(path: string): ScopeInfo {
|
|
186
|
+
if (path === DEFAULT_DURABLE_PATH) return { scope: "global" };
|
|
187
|
+
if (dirname(path) === PROJECT_DURABLE_DIR) {
|
|
188
|
+
const stem = basename(path, ".md");
|
|
189
|
+
if (stem) return { scope: "project", project: stem };
|
|
190
|
+
}
|
|
191
|
+
return { scope: "global" };
|
|
123
192
|
}
|
|
124
193
|
|
|
125
194
|
/** Stable, filesystem-safe slug from a directory path. Falls back to "default". */
|
package/src/durable.ts
ADDED
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
// Layered durable sync (issue #7), opt-in via harness.json { "autoImport": true }.
|
|
2
|
+
//
|
|
3
|
+
// One flag bundles BOTH directions so the durable seam never lags the live
|
|
4
|
+
// store and /refine output survives into NEW sessions without manual
|
|
5
|
+
// export/import ceremony:
|
|
6
|
+
//
|
|
7
|
+
// session_start → syncDurableOnStart(): layered auto-import with the same
|
|
8
|
+
// loss-free merge semantics as /harness import — global file always, the
|
|
9
|
+
// current project's file when its slug matches the session cwd (global
|
|
10
|
+
// first, so the project layer wins id collisions). Idempotent: an import
|
|
11
|
+
// that changes nothing persists nothing and stays quiet.
|
|
12
|
+
//
|
|
13
|
+
// turn_end → registerAutoExport(): layered export when the store version
|
|
14
|
+
// changed since the last export (bounded: the layer files are small).
|
|
15
|
+
// Materializes the session-restored store on the first turn of a session,
|
|
16
|
+
// so a missing/stale durable file self-heals.
|
|
17
|
+
//
|
|
18
|
+
// Both paths are visible (one notify line) and use the same persisted
|
|
19
|
+
// harness-state entries as every other mutation (/tree rollback covers them).
|
|
20
|
+
|
|
21
|
+
import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
|
|
22
|
+
import { DEFAULT_DURABLE_PATH, PROJECT_DURABLE_DIR } from "./store.js";
|
|
23
|
+
import { exportDurableLayers, getVersion, importDurableLayers } from "./store.js";
|
|
24
|
+
import type { LayerFile, LayerPaths } from "./store.js";
|
|
25
|
+
import { layerFilesFor, loadConfig, projectSlug } from "./config.js";
|
|
26
|
+
|
|
27
|
+
/** Real (homedir-based) layer paths. Tests inject their own. */
|
|
28
|
+
function layerPaths(): LayerPaths {
|
|
29
|
+
return { globalPath: DEFAULT_DURABLE_PATH, projectDir: PROJECT_DURABLE_DIR };
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
// Version at the last auto-export. -1 = never exported this session.
|
|
33
|
+
let lastExportedVersion = -1;
|
|
34
|
+
|
|
35
|
+
/** Test hook / session reset: next turn_end re-exports once (materialize). */
|
|
36
|
+
export function resetDurableSync(): void {
|
|
37
|
+
lastExportedVersion = -1;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* session_start half of the durable sync: layered import (opt-in). `files`
|
|
42
|
+
* defaults to the real layers for ctx.cwd; tests inject temp paths.
|
|
43
|
+
*/
|
|
44
|
+
export async function syncDurableOnStart(
|
|
45
|
+
pi: ExtensionAPI,
|
|
46
|
+
ctx: ExtensionContext,
|
|
47
|
+
files?: LayerFile[],
|
|
48
|
+
): Promise<void> {
|
|
49
|
+
const config = await loadConfig();
|
|
50
|
+
if (!config.autoImport) return;
|
|
51
|
+
const layers = files ?? layerFilesFor(ctx.cwd);
|
|
52
|
+
const res = await importDurableLayers(layers, {}, (snapshot, ver) => {
|
|
53
|
+
pi.appendEntry("harness-state", { state: snapshot, version: ver });
|
|
54
|
+
});
|
|
55
|
+
if (res.missingFile) return; // nothing durable yet — stay quiet
|
|
56
|
+
const changed = res.created + res.updated + res.pruned;
|
|
57
|
+
if (changed === 0) return; // in sync — no noise
|
|
58
|
+
const bits = [`${res.created} created`, `${res.updated} updated`];
|
|
59
|
+
if (res.pruned > 0) bits.push(`${res.pruned} pruned`);
|
|
60
|
+
ctx.ui.notify(`Continual Harness: durable sync imported ${res.imported} item(s) (${bits.join(", ")}).`, "info");
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* turn_end half of the durable sync: layered export when the live store
|
|
65
|
+
* changed since the last export (opt-in, bundled with autoImport). `paths`
|
|
66
|
+
* defaults to the real layer dirs; tests inject temp dirs.
|
|
67
|
+
*/
|
|
68
|
+
export function registerAutoExport(pi: ExtensionAPI, paths?: LayerPaths): void {
|
|
69
|
+
pi.on("turn_end", async (_event, ctx) => {
|
|
70
|
+
const config = await loadConfig();
|
|
71
|
+
if (!config.autoImport) return;
|
|
72
|
+
if (getVersion() === lastExportedVersion) return;
|
|
73
|
+
try {
|
|
74
|
+
const written = await exportDurableLayers(
|
|
75
|
+
paths ?? layerPaths(),
|
|
76
|
+
projectSlug(ctx.cwd),
|
|
77
|
+
);
|
|
78
|
+
lastExportedVersion = getVersion();
|
|
79
|
+
ctx.ui.notify(
|
|
80
|
+
`Continual Harness: durable state exported (${written.length} file(s)).`,
|
|
81
|
+
"info",
|
|
82
|
+
);
|
|
83
|
+
} catch (err) {
|
|
84
|
+
// Soft-fail but leave lastExportedVersion stale → retried next turn.
|
|
85
|
+
ctx.ui.notify(`Durable auto-export failed: ${(err as Error).message}`, "warning");
|
|
86
|
+
}
|
|
87
|
+
});
|
|
88
|
+
}
|