pi-continual-harness 0.5.1 → 0.6.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
@@ -178,9 +178,16 @@ for auto-refine via `proposer` in the [config](#configuration). Both paths are
178
178
  audited: the `harness-refinement` entry records which proposer ran and how many
179
179
  deltas it applied directly.
180
180
 
181
- A dedicated-model proposer (a hidden LLM call) is the obvious next alternate
182
- the interface supports, but is intentionally not shipped — non-visible model
183
- spend is a tradeoff kept as a separate decision (see `docs/ROADMAP.md`).
181
+ A dedicated-model proposer — one that makes its own (hidden) LLM call to
182
+ produce deltas directly — is the obvious next alternate. This package still
183
+ does **not** ship one (hidden model spend is a tradeoff kept as a separate
184
+ decision; see `docs/ROADMAP.md`), but it now **enables** one: when a model is
185
+ available, `/refine` injects a one-shot `complete(prompt, opts?)` into
186
+ `ProposeInput` and records any `modelCall` telemetry a proposer returns
187
+ (model, tokens, latency, ok/error) in the `harness-refinement` audit entry —
188
+ so a companion package can ship a dedicated-model proposer whose spend stays
189
+ **audited, not hidden**. Both `complete` and `modelCall` are optional;
190
+ `steering` and `dedupe` ignore them, so the default behavior is unchanged.
184
191
 
185
192
  Register your own proposer from another extension (the registry is re-exported
186
193
  from the package entry):
@@ -190,7 +197,9 @@ import { registerProposer } from "pi-continual-harness";
190
197
 
191
198
  registerProposer({
192
199
  name: "my-proposer",
193
- async propose({ evidence, state }) {
200
+ async propose({ evidence, state, complete }) {
201
+ // `complete` is injected when a model is available (undefined otherwise);
202
+ // a dedicated-model proposer calls it and returns deltas + modelCall telemetry.
194
203
  /* inspect evidence + state, return deltas (and/or a steering message) */
195
204
  return { deltas: [/* { delta, rationale } */] };
196
205
  },
@@ -228,8 +237,10 @@ Then `"my-proposer"` is selectable via `/refine --proposer my-proposer` or
228
237
 
229
238
  Open extension points (see `docs/ROADMAP.md`):
230
239
 
231
- - A dedicated-model proposer (interface ready; the hidden-model-spend tradeoff
232
- is a separate decision).
240
+ - A dedicated-model proposer (the enabler landed: `complete` injection +
241
+ `modelCall` telemetry; the proposer logic itself is left to a companion
242
+ package — the hidden-model-spend tradeoff is resolved by making the spend
243
+ audited rather than shipping it invisible).
233
244
  - Correction-side outcome signals (promotion is shipped; autonomous demotion
234
245
  is high-false-positive, so it is served by `/harness drop`, `prune --decay`,
235
246
  and the `dedupe` proposer — a fuzzy `corrections` proposer is the natural
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-continual-harness",
3
- "version": "0.5.1",
3
+ "version": "0.6.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/index.ts CHANGED
@@ -47,13 +47,26 @@ export default function continualHarness(pi: ExtensionAPI): void {
47
47
 
48
48
  export { STATE_ENTRY };
49
49
 
50
+ // Core domain types (re-exported so companion packages — e.g. a dedicated-model
51
+ // proposer — can build and validate deltas without reaching into internal paths).
52
+ export {
53
+ type AppliedDelta,
54
+ type ComponentKind,
55
+ type Delta,
56
+ type HarnessItem,
57
+ type HarnessState,
58
+ } from "./types.js";
59
+
50
60
  // Public extension API: other extensions can register their own delta proposer
51
61
  // (see src/proposer.ts) and it becomes selectable via /refine --proposer <name>
52
62
  // or the `proposer` config key.
53
63
  export {
54
64
  listProposers,
55
65
  registerProposer,
66
+ type CompleteOptions,
67
+ type CompleteResult,
56
68
  type DeltaProposer,
69
+ type ModelCallTelemetry,
57
70
  type ProposeInput,
58
71
  type ProposedDelta,
59
72
  type ProposeResult,
package/src/proposer.ts CHANGED
@@ -14,15 +14,50 @@
14
14
  // overlap, keeping the higher-importance one. Pure function of state —
15
15
  // deterministic, no model call, fully testable.
16
16
  //
17
- // A dedicated-model proposer (a hidden LLM call) is the obvious next alternate
18
- // the interface supports, but is intentionally NOT shipped here: it is the one
19
- // tradeoff (non-visible model spend) the roadmap flags as a separate decision.
17
+ // The interface supports a dedicated-model proposer — one that makes its own
18
+ // hidden LLM call to produce deltas directly, instead of delegating to the
19
+ // agent via a steering message. This package does NOT ship one (hidden model
20
+ // spend is a tradeoff the roadmap keeps as a separate decision), but it DOES
21
+ // inject a one-shot `complete` into ProposeInput when a model is available, so a
22
+ // companion package can register one. The closure is built by runRefine from
23
+ // ctx.modelRegistry; telemetry a proposer returns is recorded in the refine
24
+ // audit entry, so the spend stays visible (audited, not hidden).
20
25
  //
21
26
  // Extension is open: call registerProposer() from your own extension to add a
22
27
  // named proposer, then select it via /refine --proposer <name> or config.
23
28
 
24
29
  import type { Delta, HarnessItem, HarnessState } from "./types.js";
25
30
 
31
+ /** Options for the one-shot model completion injected into ProposeInput. */
32
+ export interface CompleteOptions {
33
+ /** Resolve and use this model id ("provider/id" or a bare id) instead of the
34
+ * active session model. Falls back to the active model if unresolvable. */
35
+ modelId?: string;
36
+ /** System prompt wrapping the user prompt. */
37
+ systemPrompt?: string;
38
+ /** Max output tokens for the completion (maps to the provider maxTokens). */
39
+ maxOutputTokens?: number;
40
+ }
41
+
42
+ /** Result of a one-shot model completion. */
43
+ export interface CompleteResult {
44
+ /** The assistant's text response. */
45
+ text: string;
46
+ /** Token usage, when the provider reports it (for the audit trail). */
47
+ usage?: { input: number; output: number };
48
+ }
49
+
50
+ /** Telemetry from a dedicated model call, surfaced in the refine audit entry so
51
+ * hidden model spend is visible (which model, tokens, latency, ok/error). */
52
+ export interface ModelCallTelemetry {
53
+ model?: string;
54
+ inputTokens?: number;
55
+ outputTokens?: number;
56
+ latencyMs?: number;
57
+ ok: boolean;
58
+ error?: string;
59
+ }
60
+
26
61
  /** Inputs to a proposer. */
27
62
  export interface ProposeInput {
28
63
  /** Recent trajectory evidence (the same string /refine gathers). */
@@ -32,6 +67,11 @@ export interface ProposeInput {
32
67
  state: HarnessState;
33
68
  /** Lookback window in turns. */
34
69
  lookback: number;
70
+ /** One-shot model completion, injected by runRefine when a model is available.
71
+ * Dedicated-model proposers call this to make a hidden completion; rule-based
72
+ * and steering proposers ignore it. Undefined when no model is resolvable, so
73
+ * a model proposer can no-op (and record an audited failure) rather than throw. */
74
+ complete?: (prompt: string, opts?: CompleteOptions) => Promise<CompleteResult>;
35
75
  }
36
76
 
37
77
  /** A single delta plus a human-readable reason for the audit trail. */
@@ -49,6 +89,10 @@ export interface ProposeResult {
49
89
  * A proposer MAY return both — deltas are applied first, then steering sent.
50
90
  */
51
91
  steeringMessage?: string;
92
+ /** Telemetry from a dedicated model call, recorded in the refine audit entry so
93
+ * hidden model spend is visible. Set by dedicated-model proposers; ignored by
94
+ * rule-based/steering ones. */
95
+ modelCall?: ModelCallTelemetry;
52
96
  }
53
97
 
54
98
  /** A strategy for turning evidence + state into (or toward) harness deltas. */
package/src/refine.ts CHANGED
@@ -18,8 +18,10 @@
18
18
  // model spend is a tradeoff the roadmap keeps as a separate decision).
19
19
 
20
20
  import type { ExtensionAPI, ExtensionCommandContext, ExtensionContext } from "@earendil-works/pi-coding-agent";
21
+ import type { Context, Model, TextContent } from "@earendil-works/pi-ai";
21
22
  import { applyDeltas, exportDurable, REFINE_ENTRY, snapshotState } from "./store.js";
22
23
  import { getProposer } from "./proposer.js";
24
+ import type { CompleteOptions, CompleteResult } from "./proposer.js";
23
25
 
24
26
  const DEFAULT_EVIDENCE_BYTES = 16000;
25
27
  export const DEFAULT_LOOKBACK_TURNS = 25;
@@ -82,9 +84,17 @@ export async function runRefine(
82
84
  ctx.ui.setStatus("harness", `Refining (last ${lookback} turns)… [${proposer.name}]`);
83
85
 
84
86
  const evidence = gatherEvidence(ctx, lookback);
87
+ // Inject a one-shot model completion (built from ctx) so dedicated-model
88
+ // proposers can make a hidden LLM call. Undefined when no model is resolvable.
89
+ const complete = buildComplete(ctx);
85
90
  // Pass a defensive copy: proposers are a public extension point and must not
86
91
  // be able to mutate the live store outside applyDeltas (no audit / no rollback).
87
- const result = await proposer.propose({ evidence, state: snapshotState(), lookback });
92
+ const result = await proposer.propose({
93
+ evidence,
94
+ state: snapshotState(),
95
+ lookback,
96
+ ...(complete ? { complete } : {}),
97
+ });
88
98
  const proposedDeltas = result.deltas ?? [];
89
99
 
90
100
  let applied = 0;
@@ -110,6 +120,9 @@ export async function runRefine(
110
120
  proposer: proposer.name,
111
121
  applied,
112
122
  rationales: proposedDeltas.map((d) => d.rationale),
123
+ // Hidden model spend made visible: dedicated-model proposers report what the
124
+ // call cost (model, tokens, latency, ok/error). Absent for rule-based/steering.
125
+ ...(result.modelCall ? { modelCall: result.modelCall } : {}),
113
126
  });
114
127
 
115
128
  if (commit) {
@@ -180,3 +193,62 @@ function gatherEvidence(ctx: ExtensionContext, lookback: number): string {
180
193
  }
181
194
  return lines.join("\n") || "(no recent trajectory evidence found)";
182
195
  }
196
+
197
+ // ---- one-shot model completion injection (for dedicated-model proposers) -------
198
+ //
199
+ // runRefine builds this closure from ctx.modelRegistry + ctx.model and passes it
200
+ // into ProposeInput.complete. A dedicated-model proposer (shipped as a companion
201
+ // package) calls it to produce deltas directly via a hidden completion; the
202
+ // telemetry it returns is recorded in the refine audit entry so the spend stays
203
+ // visible. Rule-based/steering proposers ignore it.
204
+
205
+ /** Resolve a model id ("provider/id" or a bare id) against the registry, falling
206
+ * back to the active session model. Returns undefined if nothing resolves. */
207
+ function resolveModel(ctx: ExtensionContext, modelId?: string): Model<any> | undefined {
208
+ const registry = ctx.modelRegistry;
209
+ if (modelId && registry) {
210
+ const slash = modelId.indexOf("/");
211
+ if (slash >= 0) {
212
+ const found = registry.find(modelId.slice(0, slash), modelId.slice(slash + 1));
213
+ if (found) return found;
214
+ }
215
+ const byId =
216
+ registry.getAvailable().find((m) => m.id === modelId) ??
217
+ registry.getAll().find((m) => m.id === modelId);
218
+ if (byId) return byId;
219
+ }
220
+ return ctx.model;
221
+ }
222
+
223
+ /** Build the one-shot completion closure injected into ProposeInput, or undefined
224
+ * when no model can be resolved (so a model proposer can no-op gracefully).
225
+ * Honors the agent abort signal and a per-call token budget. */
226
+ function buildComplete(
227
+ ctx: ExtensionContext,
228
+ ): ((prompt: string, opts?: CompleteOptions) => Promise<CompleteResult>) | undefined {
229
+ const registry = ctx.modelRegistry;
230
+ const active = ctx.model;
231
+ if (!registry || (!active && registry.getAvailable().length === 0)) return undefined;
232
+
233
+ return async (prompt, opts) => {
234
+ const model = resolveModel(ctx, opts?.modelId);
235
+ if (!model) throw new Error("no model available for proposer completion");
236
+ const context: Context = {
237
+ ...(opts?.systemPrompt !== undefined ? { systemPrompt: opts.systemPrompt } : {}),
238
+ messages: [{ role: "user", content: prompt, timestamp: Date.now() }],
239
+ };
240
+ const assistant = await registry.complete(model, context, {
241
+ ...(opts?.maxOutputTokens !== undefined ? { maxTokens: opts.maxOutputTokens } : {}),
242
+ ...(ctx.signal ? { signal: ctx.signal } : {}),
243
+ });
244
+ const text = assistant.content
245
+ .filter((c): c is TextContent => c.type === "text")
246
+ .map((c) => c.text)
247
+ .join("");
248
+ const usage = assistant.usage;
249
+ return {
250
+ text,
251
+ ...(usage ? { usage: { input: usage.input, output: usage.output } } : {}),
252
+ };
253
+ };
254
+ }