pi-continual-harness 0.1.1 → 0.3.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 +66 -7
- package/package.json +1 -1
- package/src/auto-refine.ts +61 -0
- package/src/config.ts +100 -0
- package/src/harness.ts +183 -0
- package/src/index.ts +6 -0
- package/src/refine.ts +49 -23
- package/src/remind.ts +50 -0
- package/src/store.ts +228 -7
package/README.md
CHANGED
|
@@ -13,8 +13,11 @@ 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
|
-
|
|
16
|
+
The durable markdown file (`.pi/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. pi-mem
|
|
20
|
+
ingestion is a future/manual step.
|
|
18
21
|
|
|
19
22
|
## Why
|
|
20
23
|
|
|
@@ -50,6 +53,27 @@ Or drop `src/index.ts` into `~/.pi/agent/extensions/`.
|
|
|
50
53
|
/refine 25 --commit # also export durable state to ~/.pi/agent/harness-state.md
|
|
51
54
|
```
|
|
52
55
|
|
|
56
|
+
Durable I/O (round-trip with pi-reflect):
|
|
57
|
+
|
|
58
|
+
```
|
|
59
|
+
/harness status # counts + durable file presence/mtime
|
|
60
|
+
/harness export [path] # write active items to a markdown file
|
|
61
|
+
/harness import [--prune] [path] # parse it back and merge (offline edits win)
|
|
62
|
+
/harness prune [--decay <days>] # drop items below the importance floor
|
|
63
|
+
/harness keep <id> # nudge importance up (+0.1)
|
|
64
|
+
/harness drop <id> # nudge importance down (−0.1)
|
|
65
|
+
```
|
|
66
|
+
|
|
67
|
+
`import` reconciles the file into the live store: items whose id matches an
|
|
68
|
+
existing entry are updated (offline edits win on content/evidence/importance),
|
|
69
|
+
new entries are created. By default nothing is deleted — `--prune` also drops
|
|
70
|
+
active items whose id is no longer in the file (inactive items are always
|
|
71
|
+
preserved). Point pi-reflect at the same file to refine it offline:
|
|
72
|
+
|
|
73
|
+
```
|
|
74
|
+
/reflect ~/.pi/agent/harness-state.md
|
|
75
|
+
```
|
|
76
|
+
|
|
53
77
|
The model-facing tools:
|
|
54
78
|
|
|
55
79
|
- `harness_list [kind]` — read current state (optionally filtered by kind).
|
|
@@ -59,6 +83,32 @@ The model-facing tools:
|
|
|
59
83
|
Active items are injected into the system prompt each turn as a structured
|
|
60
84
|
block, appended to (never replacing) the base prompt.
|
|
61
85
|
|
|
86
|
+
## Configuration
|
|
87
|
+
|
|
88
|
+
Optional config at `~/.pi/agent/harness.json` (missing or malformed → defaults):
|
|
89
|
+
|
|
90
|
+
```json
|
|
91
|
+
{
|
|
92
|
+
"durableScope": "global",
|
|
93
|
+
"remindRefine": { "enabled": false, "everyTurns": 50 },
|
|
94
|
+
"autoRefine": { "enabled": false, "everyTurns": 100, "commit": false }
|
|
95
|
+
}
|
|
96
|
+
```
|
|
97
|
+
|
|
98
|
+
- **`durableScope`** — `"global"` (default) writes the durable markdown to
|
|
99
|
+
`~/.pi/agent/harness-state.md`; `"project"` writes to
|
|
100
|
+
`~/.pi/agent/harness-state/<slug>.md` (slug derived from `cwd`) so each
|
|
101
|
+
project keeps separate state for pi-reflect.
|
|
102
|
+
- **`remindRefine`** — opt-in `turn_end` nudge. `{ "enabled": true,
|
|
103
|
+
"everyTurns": 50 }` notifies you to run `/refine` on a cadence. It is
|
|
104
|
+
informational only — it never mutates state.
|
|
105
|
+
- **`autoRefine`** — opt-in autonomous self-improvement (**off by default**).
|
|
106
|
+
When `enabled`, the agent runs `/refine` itself every `everyTurns` turns
|
|
107
|
+
(default 100). This is the package's one autonomous-self-mutation path: it
|
|
108
|
+
reuses the exact `/refine` routine (audited `REFINE_ENTRY` tagged
|
|
109
|
+
`source: "auto"`, branch-local, `/tree` rollback) and notifies before firing.
|
|
110
|
+
`commit: true` also flushes durable state on each run.
|
|
111
|
+
|
|
62
112
|
## How it works
|
|
63
113
|
|
|
64
114
|
1. `/refine` gathers recent trajectory evidence from the current session branch.
|
|
@@ -82,13 +132,22 @@ model-agnostic, and keeps every delta visible and reviewable in the transcript.
|
|
|
82
132
|
|
|
83
133
|
## Status
|
|
84
134
|
|
|
85
|
-
|
|
135
|
+
0.2.x. Implemented:
|
|
136
|
+
|
|
137
|
+
- Unified harness-state store with branch-local snapshots (`/tree` rollback).
|
|
138
|
+
- Online `/refine` + `harness_mutate` / `harness_list` tools.
|
|
139
|
+
- Two-way durable round-trip with pi-reflect (`/harness import|export|status`).
|
|
140
|
+
- Importance hygiene: `/harness prune [--decay <days>]` and `/harness keep|drop
|
|
141
|
+
<id>`.
|
|
142
|
+
- Optional config (`~/.pi/agent/harness.json`): project-local durable scope, an
|
|
143
|
+
opt-in `turn_end` reminder, and opt-in `turn_end` auto-refine (the one
|
|
144
|
+
autonomous-self-mutation path; off by default).
|
|
145
|
+
|
|
146
|
+
Open extension points (see `docs/ROADMAP.md`):
|
|
86
147
|
|
|
87
148
|
- A pluggable delta proposer (currently the agent itself, via steering).
|
|
88
|
-
-
|
|
89
|
-
-
|
|
90
|
-
default — autonomous self-mutation is a sharp edge).
|
|
91
|
-
- Importance decay/prune policy.
|
|
149
|
+
- pi-mem ingestion of the durable export.
|
|
150
|
+
- Outcome-driven importance (currently model-set + manual nudges).
|
|
92
151
|
|
|
93
152
|
## License
|
|
94
153
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "pi-continual-harness",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.3.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",
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
// turn_end auto-refine (Phase 3 of the roadmap). This is the package's ONE
|
|
2
|
+
// autonomous-self-mutation path, so it is locked behind an explicit opt-in:
|
|
3
|
+
//
|
|
4
|
+
// ~/.pi/agent/harness.json
|
|
5
|
+
// { "autoRefine": { "enabled": true, "everyTurns": 100, "commit": false } }
|
|
6
|
+
//
|
|
7
|
+
// When enabled and the cadence elapses, it runs the SAME runRefine() routine as
|
|
8
|
+
// /refine — no parallel mutation logic — so it inherits all the safety
|
|
9
|
+
// properties: structured evidence-backed deltas, an audited REFINE_ENTRY
|
|
10
|
+
// (tagged source: "auto"), branch-local snapshots, and /tree rollback.
|
|
11
|
+
//
|
|
12
|
+
// It is also visible: it notifies before firing and the steering message
|
|
13
|
+
// appears in the transcript. The decision logic is factored into
|
|
14
|
+
// evaluateAutoRefine() so it is unit-testable without a live pi runtime.
|
|
15
|
+
|
|
16
|
+
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
|
|
17
|
+
import { DEFAULT_AUTO_EVERY_TURNS, type HarnessConfig, loadConfig } from "./config.js";
|
|
18
|
+
import { runRefine } from "./refine.js";
|
|
19
|
+
|
|
20
|
+
// turnIndex of the last auto-refine (or the seeded baseline). -1 = unseen.
|
|
21
|
+
let lastTurn = -1;
|
|
22
|
+
|
|
23
|
+
/** Test hook: reset internal counter. */
|
|
24
|
+
export function resetAutoRefine(): void {
|
|
25
|
+
lastTurn = -1;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* Decide whether to auto-refine this turn. Side-effectful: the first observed
|
|
30
|
+
* turn seeds the baseline (no refine then); each fired refine resets it, which
|
|
31
|
+
* also prevents the refine turn itself from immediately re-triggering.
|
|
32
|
+
*/
|
|
33
|
+
export function evaluateAutoRefine(config: HarnessConfig, turnIndex: number): boolean {
|
|
34
|
+
if (!config.autoRefine?.enabled) return false;
|
|
35
|
+
const every = config.autoRefine.everyTurns ?? DEFAULT_AUTO_EVERY_TURNS;
|
|
36
|
+
if (every <= 0) return false;
|
|
37
|
+
if (lastTurn < 0) {
|
|
38
|
+
lastTurn = turnIndex;
|
|
39
|
+
return false;
|
|
40
|
+
}
|
|
41
|
+
if (turnIndex - lastTurn >= every) {
|
|
42
|
+
lastTurn = turnIndex;
|
|
43
|
+
return true;
|
|
44
|
+
}
|
|
45
|
+
return false;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/** Subscribe to turn_end and run /refine on the configured cadence. */
|
|
49
|
+
export function registerAutoRefine(pi: ExtensionAPI): void {
|
|
50
|
+
pi.on("turn_end", async (event, ctx) => {
|
|
51
|
+
const config = await loadConfig();
|
|
52
|
+
if (!evaluateAutoRefine(config, event.turnIndex)) return;
|
|
53
|
+
const every = config.autoRefine?.everyTurns ?? DEFAULT_AUTO_EVERY_TURNS;
|
|
54
|
+
ctx.ui.notify(`Auto-refine: running /refine (every ${every} turns, opt-in).`, "info");
|
|
55
|
+
try {
|
|
56
|
+
await runRefine(pi, ctx, { commit: config.autoRefine?.commit ?? false }, "auto");
|
|
57
|
+
} catch (err) {
|
|
58
|
+
ctx.ui.notify(`Auto-refine failed: ${(err as Error).message}`, "error");
|
|
59
|
+
}
|
|
60
|
+
});
|
|
61
|
+
}
|
package/src/config.ts
ADDED
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
// User configuration for continual-harness: ~/.pi/agent/harness.json
|
|
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).
|
|
10
|
+
//
|
|
11
|
+
// Robust by design: missing or malformed file → DEFAULT_CONFIG, never throws.
|
|
12
|
+
|
|
13
|
+
import { readFile } from "node:fs/promises";
|
|
14
|
+
import { homedir } from "node:os";
|
|
15
|
+
import { join } from "node:path";
|
|
16
|
+
import { DEFAULT_DURABLE_PATH } from "./store.js";
|
|
17
|
+
|
|
18
|
+
export const DEFAULT_EVERY_TURNS = 50;
|
|
19
|
+
export const DEFAULT_AUTO_EVERY_TURNS = 100;
|
|
20
|
+
|
|
21
|
+
export interface HarnessConfig {
|
|
22
|
+
durableScope?: "global" | "project";
|
|
23
|
+
remindRefine?: {
|
|
24
|
+
enabled?: boolean;
|
|
25
|
+
everyTurns?: number;
|
|
26
|
+
};
|
|
27
|
+
autoRefine?: {
|
|
28
|
+
enabled?: boolean;
|
|
29
|
+
everyTurns?: number;
|
|
30
|
+
commit?: boolean;
|
|
31
|
+
};
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export const DEFAULT_CONFIG: HarnessConfig = {
|
|
35
|
+
durableScope: "global",
|
|
36
|
+
remindRefine: { enabled: false, everyTurns: DEFAULT_EVERY_TURNS },
|
|
37
|
+
autoRefine: { enabled: false, everyTurns: DEFAULT_AUTO_EVERY_TURNS, commit: false },
|
|
38
|
+
};
|
|
39
|
+
|
|
40
|
+
export const CONFIG_PATH = join(homedir(), ".pi", "agent", "harness.json");
|
|
41
|
+
|
|
42
|
+
let cached: HarnessConfig | undefined;
|
|
43
|
+
|
|
44
|
+
/** Merge a (possibly partial) parsed file over the defaults. */
|
|
45
|
+
function mergeConfig(over: Partial<HarnessConfig>): HarnessConfig {
|
|
46
|
+
return {
|
|
47
|
+
durableScope: over.durableScope === "project" ? "project" : "global",
|
|
48
|
+
remindRefine: {
|
|
49
|
+
enabled: over.remindRefine?.enabled ?? false,
|
|
50
|
+
everyTurns: over.remindRefine?.everyTurns ?? DEFAULT_EVERY_TURNS,
|
|
51
|
+
},
|
|
52
|
+
autoRefine: {
|
|
53
|
+
enabled: over.autoRefine?.enabled ?? false,
|
|
54
|
+
everyTurns: over.autoRefine?.everyTurns ?? DEFAULT_AUTO_EVERY_TURNS,
|
|
55
|
+
commit: over.autoRefine?.commit ?? false,
|
|
56
|
+
},
|
|
57
|
+
};
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/** Load the config, merged over defaults. Tolerant: missing/malformed → defaults.
|
|
61
|
+
* Cached for the process lifetime (config is read once per session). */
|
|
62
|
+
export async function loadConfig(path: string = CONFIG_PATH): Promise<HarnessConfig> {
|
|
63
|
+
if (cached) return cached;
|
|
64
|
+
try {
|
|
65
|
+
const raw = await readFile(path, "utf8");
|
|
66
|
+
cached = mergeConfig(JSON.parse(raw) as Partial<HarnessConfig>);
|
|
67
|
+
} catch {
|
|
68
|
+
cached = { ...DEFAULT_CONFIG };
|
|
69
|
+
}
|
|
70
|
+
return cached;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/** Test hook: drop the in-process cache. */
|
|
74
|
+
export function resetConfigCache(): void {
|
|
75
|
+
cached = undefined;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* Resolve the durable markdown path for the configured scope.
|
|
80
|
+
* - global → ~/.pi/agent/harness-state.md (the default, unchanged)
|
|
81
|
+
* - project → ~/.pi/agent/harness-state/<slug>.md (slug derived from cwd)
|
|
82
|
+
*/
|
|
83
|
+
export function resolveDurablePath(config: HarnessConfig, cwd?: string): string {
|
|
84
|
+
if (config.durableScope !== "project") return DEFAULT_DURABLE_PATH;
|
|
85
|
+
return join(homedir(), ".pi", "agent", "harness-state", `${projectSlug(cwd)}.md`);
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/** Stable, filesystem-safe slug from a directory path. Falls back to "default". */
|
|
89
|
+
export function projectSlug(cwd?: string): string {
|
|
90
|
+
const base = (cwd ?? "").trim();
|
|
91
|
+
if (!base) return "default";
|
|
92
|
+
const slug = base
|
|
93
|
+
.replace(/[\\/]+/g, "-")
|
|
94
|
+
.replace(/[^a-z0-9-]+/gi, "")
|
|
95
|
+
.replace(/-{2,}/g, "-")
|
|
96
|
+
.replace(/^-|-$/g, "")
|
|
97
|
+
.toLowerCase()
|
|
98
|
+
.slice(-80);
|
|
99
|
+
return slug || "default";
|
|
100
|
+
}
|
package/src/harness.ts
ADDED
|
@@ -0,0 +1,183 @@
|
|
|
1
|
+
// /harness — durable I/O. The two-way round-trip seam with pi-reflect.
|
|
2
|
+
//
|
|
3
|
+
// /harness status [path] counts + durable file presence/mtime
|
|
4
|
+
// /harness export [path] write active items to a markdown file
|
|
5
|
+
// /harness import [--prune] [path] parse it back and merge (durable wins)
|
|
6
|
+
//
|
|
7
|
+
// export writes the active items to ~/.pi/agent/harness-state.md (best-effort);
|
|
8
|
+
// import parses that file and merges into the live store. Because pi-reflect
|
|
9
|
+
// edits markdown files and git-commits, pointing it at the same file closes the
|
|
10
|
+
// loop: offline refinement flows back into the online store.
|
|
11
|
+
//
|
|
12
|
+
// Manual only (no session_start auto-import): importing is an explicit,
|
|
13
|
+
// reviewable action, matching the package's "no autonomous mutation" stance.
|
|
14
|
+
|
|
15
|
+
import type { ExtensionAPI, ExtensionCommandContext } from "@earendil-works/pi-coding-agent";
|
|
16
|
+
import { stat } from "node:fs/promises";
|
|
17
|
+
import {
|
|
18
|
+
bumpImportance,
|
|
19
|
+
decayAndPrune,
|
|
20
|
+
exportDurable,
|
|
21
|
+
getState,
|
|
22
|
+
reconstructFromDurable,
|
|
23
|
+
} from "./store.js";
|
|
24
|
+
import { loadConfig, resolveDurablePath } from "./config.js";
|
|
25
|
+
import type { ComponentKind } from "./types.js";
|
|
26
|
+
|
|
27
|
+
export function registerHarness(pi: ExtensionAPI): void {
|
|
28
|
+
pi.registerCommand("harness", {
|
|
29
|
+
description:
|
|
30
|
+
"Durable harness-state I/O + importance hygiene. Subcommands: " +
|
|
31
|
+
"import [--prune] [path] · export [path] · status [path] · " +
|
|
32
|
+
"prune [--decay <days>] · keep <id> · drop <id>.",
|
|
33
|
+
handler: async (args, ctx) => {
|
|
34
|
+
const parts = args.trim().split(/\s+/).filter(Boolean);
|
|
35
|
+
const sub = (parts[0] ?? "status").toLowerCase();
|
|
36
|
+
const rest = parts.slice(1);
|
|
37
|
+
switch (sub) {
|
|
38
|
+
case "import":
|
|
39
|
+
await handleImport(pi, ctx, rest);
|
|
40
|
+
return;
|
|
41
|
+
case "export":
|
|
42
|
+
await handleExport(ctx, rest);
|
|
43
|
+
return;
|
|
44
|
+
case "prune":
|
|
45
|
+
await handlePrune(pi, ctx, rest);
|
|
46
|
+
return;
|
|
47
|
+
case "keep":
|
|
48
|
+
await handleBump(pi, ctx, rest, 0.1, "keep");
|
|
49
|
+
return;
|
|
50
|
+
case "drop":
|
|
51
|
+
await handleBump(pi, ctx, rest, -0.1, "drop");
|
|
52
|
+
return;
|
|
53
|
+
case "status":
|
|
54
|
+
default:
|
|
55
|
+
await handleStatus(ctx, rest);
|
|
56
|
+
return;
|
|
57
|
+
}
|
|
58
|
+
},
|
|
59
|
+
});
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
async function resolvePath(rest: string[], ctx: ExtensionCommandContext): Promise<string> {
|
|
63
|
+
const explicit = rest.find((a) => !a.startsWith("-"));
|
|
64
|
+
if (explicit) return explicit;
|
|
65
|
+
const config = await loadConfig();
|
|
66
|
+
return resolveDurablePath(config, ctx.cwd);
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
async function handleImport(
|
|
70
|
+
pi: ExtensionAPI,
|
|
71
|
+
ctx: ExtensionCommandContext,
|
|
72
|
+
rest: string[],
|
|
73
|
+
): Promise<void> {
|
|
74
|
+
const prune = rest.includes("--prune");
|
|
75
|
+
const path = await resolvePath(rest, ctx);
|
|
76
|
+
ctx.ui.setStatus("harness", `Importing durable state${prune ? " (prune)" : ""}…`);
|
|
77
|
+
try {
|
|
78
|
+
const res = await reconstructFromDurable(path, { prune }, (snapshot, ver) => {
|
|
79
|
+
pi.appendEntry("harness-state", { state: snapshot, version: ver });
|
|
80
|
+
});
|
|
81
|
+
if (res.missingFile) {
|
|
82
|
+
ctx.ui.notify(
|
|
83
|
+
`No durable file at ${path}. Run /refine --commit or /harness export first.`,
|
|
84
|
+
"warning",
|
|
85
|
+
);
|
|
86
|
+
return;
|
|
87
|
+
}
|
|
88
|
+
const bits = [`${res.created} created`, `${res.updated} updated`];
|
|
89
|
+
if (prune) bits.push(`${res.pruned} pruned`);
|
|
90
|
+
ctx.ui.notify(`Imported ${res.imported} item(s) from ${path} (${bits.join(", ")}).`, "info");
|
|
91
|
+
} catch (err) {
|
|
92
|
+
ctx.ui.notify(`Harness import failed: ${(err as Error).message}`, "error");
|
|
93
|
+
}
|
|
94
|
+
ctx.ui.setStatus("harness", undefined);
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
async function handleExport(ctx: ExtensionCommandContext, rest: string[]): Promise<void> {
|
|
98
|
+
const path = await resolvePath(rest, ctx);
|
|
99
|
+
ctx.ui.setStatus("harness", "Exporting durable state…");
|
|
100
|
+
try {
|
|
101
|
+
const written = await exportDurable(path);
|
|
102
|
+
const n = getState().items.filter((i) => i.active).length;
|
|
103
|
+
ctx.ui.notify(`Exported ${n} active item(s) to ${written}`, "info");
|
|
104
|
+
} catch (err) {
|
|
105
|
+
ctx.ui.notify(`Harness export failed: ${(err as Error).message}`, "error");
|
|
106
|
+
}
|
|
107
|
+
ctx.ui.setStatus("harness", undefined);
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
async function handlePrune(
|
|
111
|
+
pi: ExtensionAPI,
|
|
112
|
+
ctx: ExtensionCommandContext,
|
|
113
|
+
rest: string[],
|
|
114
|
+
): Promise<void> {
|
|
115
|
+
let decayAfterDays: number | undefined;
|
|
116
|
+
const idx = rest.indexOf("--decay");
|
|
117
|
+
if (idx >= 0) {
|
|
118
|
+
const n = Number(rest[idx + 1]);
|
|
119
|
+
decayAfterDays = Number.isFinite(n) && n > 0 ? n : undefined;
|
|
120
|
+
}
|
|
121
|
+
ctx.ui.setStatus("harness", "Pruning…");
|
|
122
|
+
try {
|
|
123
|
+
const options: { decayAfterDays?: number; decayStep?: number } = { decayStep: 0.1 };
|
|
124
|
+
if (decayAfterDays !== undefined) options.decayAfterDays = decayAfterDays;
|
|
125
|
+
const res = decayAndPrune(options, (snapshot, ver) => {
|
|
126
|
+
pi.appendEntry("harness-state", { state: snapshot, version: ver });
|
|
127
|
+
});
|
|
128
|
+
const bits = [`${res.pruned} pruned`];
|
|
129
|
+
if (decayAfterDays !== undefined) bits.push(`${res.decayed} decayed (>${decayAfterDays}d)`);
|
|
130
|
+
ctx.ui.notify(`Harness: ${bits.join(", ")}.`, "info");
|
|
131
|
+
} catch (err) {
|
|
132
|
+
ctx.ui.notify(`Harness prune failed: ${(err as Error).message}`, "error");
|
|
133
|
+
}
|
|
134
|
+
ctx.ui.setStatus("harness", undefined);
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
async function handleBump(
|
|
138
|
+
pi: ExtensionAPI,
|
|
139
|
+
ctx: ExtensionCommandContext,
|
|
140
|
+
rest: string[],
|
|
141
|
+
delta: number,
|
|
142
|
+
label: string,
|
|
143
|
+
): Promise<void> {
|
|
144
|
+
const id = rest.find((a) => a && !a.startsWith("-"));
|
|
145
|
+
if (!id) {
|
|
146
|
+
ctx.ui.notify(
|
|
147
|
+
`/harness ${label} requires an item id (see /harness status or harness_list).`,
|
|
148
|
+
"warning",
|
|
149
|
+
);
|
|
150
|
+
return;
|
|
151
|
+
}
|
|
152
|
+
const item = bumpImportance(id, delta, (snapshot, ver) => {
|
|
153
|
+
pi.appendEntry("harness-state", { state: snapshot, version: ver });
|
|
154
|
+
});
|
|
155
|
+
if (!item) {
|
|
156
|
+
ctx.ui.notify(`No harness item with id ${id}.`, "warning");
|
|
157
|
+
return;
|
|
158
|
+
}
|
|
159
|
+
const preview = item.content.length > 60 ? `${item.content.slice(0, 60)}…` : item.content;
|
|
160
|
+
ctx.ui.notify(`${label}: "${preview}" → importance ${item.importance.toFixed(2)}`, "info");
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
async function handleStatus(ctx: ExtensionCommandContext, rest: string[]): Promise<void> {
|
|
164
|
+
const path = await resolvePath(rest, ctx);
|
|
165
|
+
const items = getState().items;
|
|
166
|
+
const active = items.filter((i) => i.active);
|
|
167
|
+
const counts: Record<ComponentKind, number> = { prompt: 0, memory: 0, skill: 0, subagent: 0 };
|
|
168
|
+
for (const i of active) counts[i.kind] += 1;
|
|
169
|
+
let fileState: string;
|
|
170
|
+
try {
|
|
171
|
+
const st = await stat(path);
|
|
172
|
+
fileState = `${path} (modified ${st.mtime.toISOString()})`;
|
|
173
|
+
} catch {
|
|
174
|
+
fileState = `none at ${path}`;
|
|
175
|
+
}
|
|
176
|
+
ctx.ui.setStatus("harness", undefined);
|
|
177
|
+
ctx.ui.notify(
|
|
178
|
+
`Harness: ${active.length} active / ${items.length} total — ` +
|
|
179
|
+
`prompt ${counts.prompt}, memory ${counts.memory}, skill ${counts.skill}, subagent ${counts.subagent}. ` +
|
|
180
|
+
`Durable: ${fileState}.`,
|
|
181
|
+
"info",
|
|
182
|
+
);
|
|
183
|
+
}
|
package/src/index.ts
CHANGED
|
@@ -7,9 +7,12 @@
|
|
|
7
7
|
// See README for design rationale and the research it is grounded in.
|
|
8
8
|
|
|
9
9
|
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
|
|
10
|
+
import { registerHarness } from "./harness.js";
|
|
10
11
|
import { registerInjection } from "./inject.js";
|
|
11
12
|
import { registerRefine } from "./refine.js";
|
|
12
13
|
import { registerTools } from "./tools.js";
|
|
14
|
+
import { registerReminder } from "./remind.js";
|
|
15
|
+
import { registerAutoRefine } from "./auto-refine.js";
|
|
13
16
|
import { getState, reconstruct, STATE_ENTRY } from "./store.js";
|
|
14
17
|
|
|
15
18
|
export default function continualHarness(pi: ExtensionAPI): void {
|
|
@@ -27,6 +30,9 @@ export default function continualHarness(pi: ExtensionAPI): void {
|
|
|
27
30
|
registerInjection(pi);
|
|
28
31
|
registerTools(pi);
|
|
29
32
|
registerRefine(pi);
|
|
33
|
+
registerHarness(pi);
|
|
34
|
+
registerReminder(pi);
|
|
35
|
+
registerAutoRefine(pi);
|
|
30
36
|
}
|
|
31
37
|
|
|
32
38
|
export { STATE_ENTRY };
|
package/src/refine.ts
CHANGED
|
@@ -12,11 +12,11 @@
|
|
|
12
12
|
// agent loop, is model-agnostic, and keeps every delta visible and reviewable
|
|
13
13
|
// in the transcript. No hidden model calls.
|
|
14
14
|
|
|
15
|
-
import type { ExtensionAPI, ExtensionCommandContext } from "@earendil-works/pi-coding-agent";
|
|
15
|
+
import type { ExtensionAPI, ExtensionCommandContext, ExtensionContext } from "@earendil-works/pi-coding-agent";
|
|
16
16
|
import { exportDurable, REFINE_ENTRY } from "./store.js";
|
|
17
17
|
|
|
18
|
-
const DEFAULT_LOOKBACK_TURNS = 25;
|
|
19
18
|
const DEFAULT_EVIDENCE_BYTES = 16000;
|
|
19
|
+
export const DEFAULT_LOOKBACK_TURNS = 25;
|
|
20
20
|
|
|
21
21
|
// Minimal entry shape for trajectory walking; kept loose to avoid coupling to
|
|
22
22
|
// pi's internal session entry types.
|
|
@@ -35,29 +35,55 @@ export function registerRefine(pi: ExtensionAPI): void {
|
|
|
35
35
|
"Online self-improvement: review recent trajectory and propose evidence-backed CRUD deltas to the harness state. Usage: /refine [lookback-turns] [--commit]",
|
|
36
36
|
handler: async (args, ctx) => {
|
|
37
37
|
const { lookback, commit } = parseArgs(args);
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
38
|
+
await runRefine(pi, ctx, { lookback, commit });
|
|
39
|
+
},
|
|
40
|
+
});
|
|
41
|
+
}
|
|
42
42
|
|
|
43
|
-
|
|
44
|
-
|
|
43
|
+
export interface RefineOptions {
|
|
44
|
+
lookback?: number;
|
|
45
|
+
commit?: boolean;
|
|
46
|
+
}
|
|
45
47
|
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
} catch (err) {
|
|
53
|
-
ctx.ui.notify(`Durable export failed: ${(err as Error).message}`, "warning");
|
|
54
|
-
}
|
|
55
|
-
}
|
|
48
|
+
export interface RefineResult {
|
|
49
|
+
evidenceBytes: number;
|
|
50
|
+
lookback: number;
|
|
51
|
+
commit: boolean;
|
|
52
|
+
source: "manual" | "auto";
|
|
53
|
+
}
|
|
56
54
|
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
55
|
+
/** Core refine routine shared by /refine (manual) and turn_end auto-refine.
|
|
56
|
+
* Gathers trajectory evidence, records an audit entry, optionally flushes the
|
|
57
|
+
* durable state, then sends the steering user message that drives the agent to
|
|
58
|
+
* propose evidence-backed CRUD deltas. `source` tags the audit entry so
|
|
59
|
+
* autonomous runs are distinguishable in the transcript. */
|
|
60
|
+
export async function runRefine(
|
|
61
|
+
pi: ExtensionAPI,
|
|
62
|
+
ctx: ExtensionContext,
|
|
63
|
+
options: RefineOptions = {},
|
|
64
|
+
source: "manual" | "auto" = "manual",
|
|
65
|
+
): Promise<RefineResult> {
|
|
66
|
+
const lookback = options.lookback ?? DEFAULT_LOOKBACK_TURNS;
|
|
67
|
+
const commit = options.commit ?? false;
|
|
68
|
+
ctx.ui.setStatus("harness", `Refining (last ${lookback} turns)…`);
|
|
69
|
+
const evidence = gatherEvidence(ctx, lookback);
|
|
70
|
+
const prompt = buildSteeringPrompt(evidence, lookback);
|
|
71
|
+
// Audit trail (branchable via /tree). `source` distinguishes manual /refine
|
|
72
|
+
// from opt-in auto-refine so autonomous runs are visible in the transcript.
|
|
73
|
+
pi.appendEntry(REFINE_ENTRY, { lookback, commit, startedAt: Date.now(), source });
|
|
74
|
+
if (commit) {
|
|
75
|
+
// Flush current state to the durable file first so the agent refines
|
|
76
|
+
// against the same view pi-reflect / pi-mem would see.
|
|
77
|
+
try {
|
|
78
|
+
const path = await exportDurable();
|
|
79
|
+
ctx.ui.notify(`Durable state exported to ${path}`, "info");
|
|
80
|
+
} catch (err) {
|
|
81
|
+
ctx.ui.notify(`Durable export failed: ${(err as Error).message}`, "warning");
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
pi.sendUserMessage(prompt);
|
|
85
|
+
ctx.ui.setStatus("harness", undefined);
|
|
86
|
+
return { evidenceBytes: evidence.length, lookback, commit, source };
|
|
61
87
|
}
|
|
62
88
|
|
|
63
89
|
function parseArgs(args: string): { lookback: number; commit: boolean } {
|
|
@@ -71,7 +97,7 @@ function parseArgs(args: string): { lookback: number; commit: boolean } {
|
|
|
71
97
|
return { lookback, commit };
|
|
72
98
|
}
|
|
73
99
|
|
|
74
|
-
function gatherEvidence(ctx:
|
|
100
|
+
function gatherEvidence(ctx: ExtensionContext, lookback: number): string {
|
|
75
101
|
const entries = ctx.sessionManager.getBranch() as AnyEntry[];
|
|
76
102
|
const messages = entries.filter((e) => e.type === "message" && e.message);
|
|
77
103
|
const recent = messages.slice(-lookback * 2); // ~2 entries per turn (user+assistant)
|
package/src/remind.ts
ADDED
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
// turn_end reminder (Phase 2 of the roadmap). Purely informational — it nudges
|
|
2
|
+
// the user to run /refine on a cadence; it never mutates state. Opt-in via
|
|
3
|
+
// harness.json { remindRefine: { enabled: true, everyTurns: 50 } }.
|
|
4
|
+
//
|
|
5
|
+
// The decision logic is factored into evaluateReminder() so it is unit-testable
|
|
6
|
+
// without a live pi runtime; registerReminder() is thin glue over pi.on.
|
|
7
|
+
|
|
8
|
+
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
|
|
9
|
+
import { DEFAULT_EVERY_TURNS, type HarnessConfig, loadConfig } from "./config.js";
|
|
10
|
+
|
|
11
|
+
// turnIndex of the last reminder (or the baseline we seeded from). -1 = unseen.
|
|
12
|
+
let lastTurn = -1;
|
|
13
|
+
|
|
14
|
+
/** Test hook: reset internal counter. */
|
|
15
|
+
export function resetReminder(): void {
|
|
16
|
+
lastTurn = -1;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* Decide whether to remind this turn. Side-effectful: the first observed turn
|
|
21
|
+
* seeds the baseline (no reminder then), and each fired reminder resets it.
|
|
22
|
+
* Returns true when it is time to nudge.
|
|
23
|
+
*/
|
|
24
|
+
export function evaluateReminder(config: HarnessConfig, turnIndex: number): boolean {
|
|
25
|
+
if (!config.remindRefine?.enabled) return false;
|
|
26
|
+
const every = config.remindRefine.everyTurns ?? DEFAULT_EVERY_TURNS;
|
|
27
|
+
if (every <= 0) return false;
|
|
28
|
+
if (lastTurn < 0) {
|
|
29
|
+
lastTurn = turnIndex;
|
|
30
|
+
return false;
|
|
31
|
+
}
|
|
32
|
+
if (turnIndex - lastTurn >= every) {
|
|
33
|
+
lastTurn = turnIndex;
|
|
34
|
+
return true;
|
|
35
|
+
}
|
|
36
|
+
return false;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/** Subscribe to turn_end and notify on the configured cadence. */
|
|
40
|
+
export function registerReminder(pi: ExtensionAPI): void {
|
|
41
|
+
pi.on("turn_end", async (event, ctx) => {
|
|
42
|
+
const config = await loadConfig();
|
|
43
|
+
if (!evaluateReminder(config, event.turnIndex)) return;
|
|
44
|
+
const every = config.remindRefine?.everyTurns ?? DEFAULT_EVERY_TURNS;
|
|
45
|
+
ctx.ui.notify(
|
|
46
|
+
`${every} turns since the last check — consider running /refine to refine harness state.`,
|
|
47
|
+
"info",
|
|
48
|
+
);
|
|
49
|
+
});
|
|
50
|
+
}
|
package/src/store.ts
CHANGED
|
@@ -10,7 +10,7 @@
|
|
|
10
10
|
// to a markdown file pi-reflect can read and refine offline, and pi-mem can
|
|
11
11
|
// ingest. Best-effort; the package works without it.
|
|
12
12
|
|
|
13
|
-
import { mkdir, writeFile } from "node:fs/promises";
|
|
13
|
+
import { mkdir, readFile, writeFile } from "node:fs/promises";
|
|
14
14
|
import { homedir } from "node:os";
|
|
15
15
|
import { dirname, join } from "node:path";
|
|
16
16
|
import type { AppliedDelta, ComponentKind, Delta, HarnessItem, HarnessState } from "./types.js";
|
|
@@ -18,7 +18,7 @@ import type { AppliedDelta, ComponentKind, Delta, HarnessItem, HarnessState } fr
|
|
|
18
18
|
const STATE_ENTRY = "harness-state";
|
|
19
19
|
const REFINE_ENTRY = "harness-refinement";
|
|
20
20
|
|
|
21
|
-
const DEFAULT_DURABLE_PATH = join(homedir(), ".pi", "agent", "harness-state.md");
|
|
21
|
+
export const DEFAULT_DURABLE_PATH = join(homedir(), ".pi", "agent", "harness-state.md");
|
|
22
22
|
|
|
23
23
|
const IMPORTANCE_FLOOR = 0.3;
|
|
24
24
|
|
|
@@ -120,15 +120,49 @@ export function reconstruct(entries: Iterable<unknown>): void {
|
|
|
120
120
|
version = 0;
|
|
121
121
|
}
|
|
122
122
|
|
|
123
|
-
/**
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
123
|
+
/**
|
|
124
|
+
* Age importance of stale items, then prune below the floor.
|
|
125
|
+
* - decayAfterDays: if set, items whose updatedAt is older than this get
|
|
126
|
+
* importance -= decayStep (default 0.1) before pruning. Time-since-update
|
|
127
|
+
* is a weak proxy for staleness; pair with /harness keep|drop for signal.
|
|
128
|
+
* - Items below IMPORTANCE_FLOOR after decay are removed.
|
|
129
|
+
*/
|
|
130
|
+
export function decayAndPrune(
|
|
131
|
+
options: { decayAfterDays?: number; decayStep?: number } = {},
|
|
132
|
+
persist: (snapshot: HarnessState, version: number) => void,
|
|
133
|
+
): { pruned: number; decayed: number } {
|
|
134
|
+
let decayed = 0;
|
|
135
|
+
const step = options.decayStep ?? 0.1;
|
|
136
|
+
const ms = options.decayAfterDays !== undefined ? options.decayAfterDays * 86_400_000 : undefined;
|
|
137
|
+
const now = Date.now();
|
|
138
|
+
if (ms !== undefined) {
|
|
139
|
+
for (const i of state.items) {
|
|
140
|
+
if (now - i.updatedAt > ms) {
|
|
141
|
+
i.importance = clamp(i.importance - step);
|
|
142
|
+
decayed += 1;
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
}
|
|
127
146
|
const before = state.items.length;
|
|
128
147
|
state.items = state.items.filter((i) => i.importance >= IMPORTANCE_FLOOR);
|
|
129
148
|
version += 1;
|
|
130
149
|
persist(state, version);
|
|
131
|
-
return { pruned: before - state.items.length };
|
|
150
|
+
return { pruned: before - state.items.length, decayed };
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
/** Nudge an item's importance by delta (clamped to [0,1]) and touch updatedAt. */
|
|
154
|
+
export function bumpImportance(
|
|
155
|
+
id: string,
|
|
156
|
+
delta: number,
|
|
157
|
+
persist: (snapshot: HarnessState, version: number) => void,
|
|
158
|
+
): HarnessItem | undefined {
|
|
159
|
+
const item = state.items.find((i) => i.id === id);
|
|
160
|
+
if (!item) return undefined;
|
|
161
|
+
item.importance = clamp(item.importance + delta);
|
|
162
|
+
item.updatedAt = Date.now();
|
|
163
|
+
version += 1;
|
|
164
|
+
persist(state, version);
|
|
165
|
+
return item;
|
|
132
166
|
}
|
|
133
167
|
|
|
134
168
|
export { STATE_ENTRY, REFINE_ENTRY, IMPORTANCE_FLOOR };
|
|
@@ -167,3 +201,190 @@ function titleFor(kind: ComponentKind): string {
|
|
|
167
201
|
return "Sub-agent specs";
|
|
168
202
|
}
|
|
169
203
|
}
|
|
204
|
+
|
|
205
|
+
// ---- Durable import (round-trip seam with pi-reflect) ---------------------
|
|
206
|
+
//
|
|
207
|
+
// exportDurable() is write-only by design. reconstructFromDurable() closes the
|
|
208
|
+
// loop: it parses the markdown back into items and merges them into the live
|
|
209
|
+
// store, so offline edits pi-reflect makes to harness-state.md flow back in.
|
|
210
|
+
//
|
|
211
|
+
// Merge semantics (predictable, loss-free by default):
|
|
212
|
+
// - parsed item whose id matches an existing item → UPDATE in place
|
|
213
|
+
// (durable wins on content/evidence/importance; reactivated; createdAt kept).
|
|
214
|
+
// - parsed item with a new/foreign id → CREATE.
|
|
215
|
+
// - items in the store but absent from the file → KEPT by default.
|
|
216
|
+
// Pass { prune: true } to also drop active items whose id is not in the
|
|
217
|
+
// file (inactive items are always preserved — the durable export never
|
|
218
|
+
// contains them, so they cannot have been "deleted" by pi-reflect).
|
|
219
|
+
|
|
220
|
+
export interface DurableImportResult {
|
|
221
|
+
/** Items successfully parsed from the file. */
|
|
222
|
+
imported: number;
|
|
223
|
+
created: number;
|
|
224
|
+
updated: number;
|
|
225
|
+
/** Only non-zero when { prune: true }. */
|
|
226
|
+
pruned: number;
|
|
227
|
+
/** True if the file did not exist (no-op). */
|
|
228
|
+
missingFile: boolean;
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
interface ParsedItem {
|
|
232
|
+
id?: string;
|
|
233
|
+
kind: ComponentKind;
|
|
234
|
+
importance: number;
|
|
235
|
+
content: string;
|
|
236
|
+
evidence: string;
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
// Section title → kind. Exact export titles first, then tolerant keyword
|
|
240
|
+
// fallbacks so pi-reflect's edits to headings still parse.
|
|
241
|
+
const TITLE_TO_KIND: Array<[RegExp, ComponentKind]> = [
|
|
242
|
+
[/supplemental prompt notes/i, "prompt"],
|
|
243
|
+
[/memory facts/i, "memory"],
|
|
244
|
+
[/skill descriptions/i, "skill"],
|
|
245
|
+
[/sub-?agent specs/i, "subagent"],
|
|
246
|
+
[/\bprompt\b/i, "prompt"],
|
|
247
|
+
[/\bmemory\b/i, "memory"],
|
|
248
|
+
[/\bskills?\b/i, "skill"],
|
|
249
|
+
[/\bsub-?agents?\b/i, "subagent"],
|
|
250
|
+
];
|
|
251
|
+
|
|
252
|
+
function titleToKind(title: string): ComponentKind | undefined {
|
|
253
|
+
for (const [re, kind] of TITLE_TO_KIND) if (re.test(title)) return kind;
|
|
254
|
+
return undefined;
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
const RE_H2 = /^##\s+(.*)$/;
|
|
258
|
+
const RE_ID_BULLET = /^-\s+\*\*\[([^\]]+)\]\*\*\s*\(importance\s+([\d.]+)\)\s*(.*)$/;
|
|
259
|
+
const RE_PLAIN_BULLET = /^-\s+(.+)$/;
|
|
260
|
+
const RE_EVIDENCE = /^\s+-\s+evidence:\s*(.*)$/i;
|
|
261
|
+
|
|
262
|
+
/** Parse a durable markdown export into items. Tolerant of pi-reflect's edits. */
|
|
263
|
+
export function parseDurable(text: string): ParsedItem[] {
|
|
264
|
+
const out: ParsedItem[] = [];
|
|
265
|
+
let kind: ComponentKind | undefined;
|
|
266
|
+
let pending: ParsedItem | null = null;
|
|
267
|
+
const flush = (): void => {
|
|
268
|
+
if (pending && pending.content) out.push(pending);
|
|
269
|
+
pending = null;
|
|
270
|
+
};
|
|
271
|
+
|
|
272
|
+
for (const raw of text.split(/\r?\n/)) {
|
|
273
|
+
const line = raw.replace(/\s+$/, "");
|
|
274
|
+
const h2 = line.match(RE_H2);
|
|
275
|
+
if (h2) {
|
|
276
|
+
flush();
|
|
277
|
+
kind = titleToKind(h2[1]!.trim());
|
|
278
|
+
continue;
|
|
279
|
+
}
|
|
280
|
+
if (/^#/.test(line)) {
|
|
281
|
+
flush(); // any other header (h1, h3, …)
|
|
282
|
+
continue;
|
|
283
|
+
}
|
|
284
|
+
if (!kind) continue; // ignore bullets outside a known section
|
|
285
|
+
|
|
286
|
+
const ev = line.match(RE_EVIDENCE);
|
|
287
|
+
if (ev) {
|
|
288
|
+
if (pending) pending.evidence = ev[1]!.trim();
|
|
289
|
+
continue;
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
const idm = line.match(RE_ID_BULLET);
|
|
293
|
+
if (idm) {
|
|
294
|
+
flush();
|
|
295
|
+
const imp = Number(idm[2]!);
|
|
296
|
+
pending = {
|
|
297
|
+
id: idm[1]!.trim(),
|
|
298
|
+
kind,
|
|
299
|
+
importance: Number.isNaN(imp) ? 0.5 : imp,
|
|
300
|
+
content: idm[3]!.trim(),
|
|
301
|
+
evidence: "",
|
|
302
|
+
};
|
|
303
|
+
continue;
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
const pm = line.match(RE_PLAIN_BULLET);
|
|
307
|
+
if (pm) {
|
|
308
|
+
flush();
|
|
309
|
+
const body = pm[1]!.trim();
|
|
310
|
+
const idInBody = body.match(/^\*\*\[([^\]]+)\]\*\*/);
|
|
311
|
+
pending = {
|
|
312
|
+
kind,
|
|
313
|
+
importance: 0.5,
|
|
314
|
+
content: body.replace(/^\*\*\[([^\]]+)\]\*\*\s*/, "").trim(),
|
|
315
|
+
evidence: "",
|
|
316
|
+
};
|
|
317
|
+
if (idInBody) pending.id = idInBody[1]!.trim();
|
|
318
|
+
continue;
|
|
319
|
+
}
|
|
320
|
+
}
|
|
321
|
+
flush();
|
|
322
|
+
return out.filter((p) => p.content && !/^\(?no active items\)?$/i.test(p.content));
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
/**
|
|
326
|
+
* Parse the durable file and merge it into the live store, then persist a
|
|
327
|
+
* snapshot. See the file-level comment above for merge semantics.
|
|
328
|
+
*/
|
|
329
|
+
export async function reconstructFromDurable(
|
|
330
|
+
path: string,
|
|
331
|
+
options: { prune?: boolean },
|
|
332
|
+
persist: (snapshot: HarnessState, version: number) => void,
|
|
333
|
+
): Promise<DurableImportResult> {
|
|
334
|
+
let text: string;
|
|
335
|
+
try {
|
|
336
|
+
text = await readFile(path, "utf8");
|
|
337
|
+
} catch {
|
|
338
|
+
return { imported: 0, created: 0, updated: 0, pruned: 0, missingFile: true };
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
const parsed = parseDurable(text);
|
|
342
|
+
const fileIds = new Set(parsed.map((p) => p.id).filter((id): id is string => Boolean(id)));
|
|
343
|
+
const existingById = new Map(state.items.map((i) => [i.id, i] as const));
|
|
344
|
+
const preActiveIds = new Set(state.items.filter((i) => i.active).map((i) => i.id));
|
|
345
|
+
|
|
346
|
+
let created = 0;
|
|
347
|
+
let updated = 0;
|
|
348
|
+
const now = Date.now();
|
|
349
|
+
for (const p of parsed) {
|
|
350
|
+
const existing = p.id ? existingById.get(p.id) : undefined;
|
|
351
|
+
if (existing) {
|
|
352
|
+
// Durable wins; reactivate; keep createdAt.
|
|
353
|
+
existing.content = p.content;
|
|
354
|
+
existing.evidence = p.evidence;
|
|
355
|
+
existing.importance = clamp(p.importance);
|
|
356
|
+
existing.active = true;
|
|
357
|
+
existing.updatedAt = now;
|
|
358
|
+
updated += 1;
|
|
359
|
+
} else {
|
|
360
|
+
const id = p.id && /^h_/.test(p.id) ? p.id : genId();
|
|
361
|
+
state.items.push({
|
|
362
|
+
id,
|
|
363
|
+
kind: p.kind,
|
|
364
|
+
content: p.content,
|
|
365
|
+
evidence: p.evidence,
|
|
366
|
+
importance: clamp(p.importance),
|
|
367
|
+
active: true,
|
|
368
|
+
createdAt: now,
|
|
369
|
+
updatedAt: now,
|
|
370
|
+
});
|
|
371
|
+
created += 1;
|
|
372
|
+
}
|
|
373
|
+
}
|
|
374
|
+
|
|
375
|
+
let pruned = 0;
|
|
376
|
+
if (options.prune) {
|
|
377
|
+
const before = state.items.length;
|
|
378
|
+
state.items = state.items.filter((i) => {
|
|
379
|
+
if (!i.active) return true; // inactive items are never touched by durable I/O
|
|
380
|
+
if (!preActiveIds.has(i.id)) return true; // created during this import
|
|
381
|
+
if (fileIds.has(i.id)) return true; // present in the file
|
|
382
|
+
return false; // was active before, absent from the file → drop
|
|
383
|
+
});
|
|
384
|
+
pruned = before - state.items.length;
|
|
385
|
+
}
|
|
386
|
+
|
|
387
|
+
version += 1;
|
|
388
|
+
persist(state, version);
|
|
389
|
+
return { imported: parsed.length, created, updated, pruned, missingFile: false };
|
|
390
|
+
}
|