pi-continual-harness 0.2.0 → 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 CHANGED
@@ -59,6 +59,9 @@ Durable I/O (round-trip with pi-reflect):
59
59
  /harness status # counts + durable file presence/mtime
60
60
  /harness export [path] # write active items to a markdown file
61
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)
62
65
  ```
63
66
 
64
67
  `import` reconciles the file into the live store: items whose id matches an
@@ -80,6 +83,32 @@ The model-facing tools:
80
83
  Active items are injected into the system prompt each turn as a structured
81
84
  block, appended to (never replacing) the base prompt.
82
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
+
83
112
  ## How it works
84
113
 
85
114
  1. `/refine` gathers recent trajectory evidence from the current session branch.
@@ -103,15 +132,22 @@ model-agnostic, and keeps every delta visible and reviewable in the transcript.
103
132
 
104
133
  ## Status
105
134
 
106
- 0.1.x. The durable round-trip with pi-reflect is implemented
107
- (`/harness import|export|status`). Open extension points:
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`):
108
147
 
109
148
  - A pluggable delta proposer (currently the agent itself, via steering).
110
- - Project-local vs global durable paths.
111
- - Optional `turn_end` auto-trigger behind a setting (intentionally off by
112
- default — autonomous self-mutation is a sharp edge).
113
- - Importance decay/prune policy — `decayAndPrune()` exists but is not yet wired.
114
- - pi-mem ingestion of the durable export (manual/future).
149
+ - pi-mem ingestion of the durable export.
150
+ - Outcome-driven importance (currently model-set + manual nudges).
115
151
 
116
152
  ## License
117
153
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-continual-harness",
3
- "version": "0.2.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 CHANGED
@@ -14,14 +14,22 @@
14
14
 
15
15
  import type { ExtensionAPI, ExtensionCommandContext } from "@earendil-works/pi-coding-agent";
16
16
  import { stat } from "node:fs/promises";
17
- import { DEFAULT_DURABLE_PATH, exportDurable, getState, reconstructFromDurable } from "./store.js";
17
+ import {
18
+ bumpImportance,
19
+ decayAndPrune,
20
+ exportDurable,
21
+ getState,
22
+ reconstructFromDurable,
23
+ } from "./store.js";
24
+ import { loadConfig, resolveDurablePath } from "./config.js";
18
25
  import type { ComponentKind } from "./types.js";
19
26
 
20
27
  export function registerHarness(pi: ExtensionAPI): void {
21
28
  pi.registerCommand("harness", {
22
29
  description:
23
- "Durable harness-state I/O (round-trip seam with pi-reflect). " +
24
- "Subcommands: import [--prune] [path] · export [path] · status [path].",
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>.",
25
33
  handler: async (args, ctx) => {
26
34
  const parts = args.trim().split(/\s+/).filter(Boolean);
27
35
  const sub = (parts[0] ?? "status").toLowerCase();
@@ -33,6 +41,15 @@ export function registerHarness(pi: ExtensionAPI): void {
33
41
  case "export":
34
42
  await handleExport(ctx, rest);
35
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;
36
53
  case "status":
37
54
  default:
38
55
  await handleStatus(ctx, rest);
@@ -42,8 +59,11 @@ export function registerHarness(pi: ExtensionAPI): void {
42
59
  });
43
60
  }
44
61
 
45
- function resolvePath(rest: string[]): string {
46
- return rest.find((a) => !a.startsWith("-")) ?? DEFAULT_DURABLE_PATH;
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);
47
67
  }
48
68
 
49
69
  async function handleImport(
@@ -52,7 +72,7 @@ async function handleImport(
52
72
  rest: string[],
53
73
  ): Promise<void> {
54
74
  const prune = rest.includes("--prune");
55
- const path = resolvePath(rest);
75
+ const path = await resolvePath(rest, ctx);
56
76
  ctx.ui.setStatus("harness", `Importing durable state${prune ? " (prune)" : ""}…`);
57
77
  try {
58
78
  const res = await reconstructFromDurable(path, { prune }, (snapshot, ver) => {
@@ -75,7 +95,7 @@ async function handleImport(
75
95
  }
76
96
 
77
97
  async function handleExport(ctx: ExtensionCommandContext, rest: string[]): Promise<void> {
78
- const path = resolvePath(rest);
98
+ const path = await resolvePath(rest, ctx);
79
99
  ctx.ui.setStatus("harness", "Exporting durable state…");
80
100
  try {
81
101
  const written = await exportDurable(path);
@@ -87,8 +107,61 @@ async function handleExport(ctx: ExtensionCommandContext, rest: string[]): Promi
87
107
  ctx.ui.setStatus("harness", undefined);
88
108
  }
89
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
+
90
163
  async function handleStatus(ctx: ExtensionCommandContext, rest: string[]): Promise<void> {
91
- const path = resolvePath(rest);
164
+ const path = await resolvePath(rest, ctx);
92
165
  const items = getState().items;
93
166
  const active = items.filter((i) => i.active);
94
167
  const counts: Record<ComponentKind, number> = { prompt: 0, memory: 0, skill: 0, subagent: 0 };
package/src/index.ts CHANGED
@@ -11,6 +11,8 @@ import { registerHarness } from "./harness.js";
11
11
  import { registerInjection } from "./inject.js";
12
12
  import { registerRefine } from "./refine.js";
13
13
  import { registerTools } from "./tools.js";
14
+ import { registerReminder } from "./remind.js";
15
+ import { registerAutoRefine } from "./auto-refine.js";
14
16
  import { getState, reconstruct, STATE_ENTRY } from "./store.js";
15
17
 
16
18
  export default function continualHarness(pi: ExtensionAPI): void {
@@ -29,6 +31,8 @@ export default function continualHarness(pi: ExtensionAPI): void {
29
31
  registerTools(pi);
30
32
  registerRefine(pi);
31
33
  registerHarness(pi);
34
+ registerReminder(pi);
35
+ registerAutoRefine(pi);
32
36
  }
33
37
 
34
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
- ctx.ui.setStatus("harness", `Refining (last ${lookback} turns)…`);
39
-
40
- const evidence = gatherEvidence(ctx, lookback);
41
- const prompt = buildSteeringPrompt(evidence, lookback);
38
+ await runRefine(pi, ctx, { lookback, commit });
39
+ },
40
+ });
41
+ }
42
42
 
43
- // Record that a refinement was kicked off (audit trail; branchable).
44
- pi.appendEntry(REFINE_ENTRY, { lookback, commit, startedAt: Date.now() });
43
+ export interface RefineOptions {
44
+ lookback?: number;
45
+ commit?: boolean;
46
+ }
45
47
 
46
- if (commit) {
47
- // Flush current state to the durable file first so the agent refines
48
- // against the same view pi-reflect / pi-mem would see.
49
- try {
50
- const path = await exportDurable();
51
- ctx.ui.notify(`Durable state exported to ${path}`, "info");
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
- pi.sendUserMessage(prompt);
58
- ctx.ui.setStatus("harness", undefined);
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: ExtensionCommandContext, lookback: number): string {
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
@@ -120,15 +120,49 @@ export function reconstruct(entries: Iterable<unknown>): void {
120
120
  version = 0;
121
121
  }
122
122
 
123
- /** Decrement importance of inactive/low-signal items; prune below the floor. */
124
- export function decayAndPrune(persist: (snapshot: HarnessState, version: number) => void): {
125
- pruned: number;
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 };