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 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 file (`~/.pi/agent/harness-state.md`) is the composition seam,
17
- and it is **two-way**: `/refine --commit` and `/harness export` write it;
18
- `/harness import` parses it back and merges into the live store (offline edits
19
- win on conflict), so refinements pi-reflect makes flow back online. `/harness push-mem`
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 to ~/.pi/agent/harness-state.md
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 file presence/mtime
62
- /harness export [path] # write active items to a markdown file
63
- /harness import [--prune] [path] # parse it back and merge (offline edits win)
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, and `keep`/`drop` item ids all
71
- autocomplete in the TUI as you type after `/harness `.
72
-
73
- `import` reconciles the file into the live store: items whose id matches an
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 the same file to refine it offline:
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
- "durableScope": "global",
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
- - **`durableScope`** — `"global"` (default) writes the durable markdown to
130
- `~/.pi/agent/harness-state.md`; `"project"` writes to
131
- `~/.pi/agent/harness-state/<slug>.md` (slug derived from `cwd`) so each
132
- project keeps separate state for pi-reflect.
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 dedupe directly. See [Proposers](#proposers).
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`, `push-mem`,
209
- `status`) operate on the **whole store** by design — they are explicit human
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: drops near-duplicate active items (token-overlap ≥ 0.6), keeping the higher-importance one. No model call. |
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.8.x. Implemented:
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
- - Optional config (`~/.pi/agent/harness.json`): project-local durable scope, an
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 two opt-in autonomous
328
- paths (both off by default).
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.8.1",
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
- // Phase 2 introduces this file (optional; missing → defaults) to configure:
4
- // - durableScope: where the durable markdown lives. "global" (default,
5
- // ~/.pi/agent/harness-state.md — backward compatible) or "project"
6
- // (~/.pi/agent/harness-state/<slug>.md, slug derived from cwd) so different
7
- // projects keep separate harness state for pi-reflect.
8
- // - remindRefine: opt-in turn_end nudge to run /refine (Phase 2 = reminder
9
- // only; Phase 3 adds opt-in auto-refine).
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
- * Resolve the durable markdown path for the configured scope.
117
- * - global → ~/.pi/agent/harness-state.md (the default, unchanged)
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 join(homedir(), ".pi", "agent", "harness-state", `${projectSlug(cwd)}.md`);
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
+ }