pi-pier 0.3.0 → 0.3.1
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 +12 -11
- package/package.json +2 -2
- package/schemas/role-manifest.schema.json +7 -1
- package/skills/pi-herdr-workflow/SKILL.md +4 -5
- package/src/ansi-text.ts +62 -11
- package/src/ask-dialog.ts +308 -0
- package/src/ask-user.ts +113 -112
- package/src/bootstrap.ts +8 -12
- package/src/compact-coordinator.ts +278 -261
- package/src/compact-economics-core.ts +77 -145
- package/src/config-catalog-core.ts +77 -197
- package/src/config-command.ts +271 -64
- package/src/dashboard-command.ts +38 -19
- package/src/efficiency-config-core.ts +287 -484
- package/src/efficiency-store.ts +16 -29
- package/src/focus-poller.ts +14 -35
- package/src/gc-core.ts +10 -36
- package/src/herdr-client.ts +82 -208
- package/src/history-store.ts +36 -23
- package/src/index-gates.ts +124 -0
- package/src/index-locks.ts +2 -7
- package/src/index-master.ts +24 -18
- package/src/index-notices.ts +28 -15
- package/src/index-pipe.ts +17 -12
- package/src/index-roles.ts +306 -0
- package/src/index-runtime.ts +2 -6
- package/src/index-worker.ts +4 -8
- package/src/index.ts +305 -564
- package/src/jev-client.ts +12 -17
- package/src/jev-core.ts +137 -87
- package/src/ledger.ts +13 -21
- package/src/lock-core.ts +28 -56
- package/src/manifest-compose.ts +35 -20
- package/src/observation-core.ts +52 -100
- package/src/pane-title.ts +19 -38
- package/src/pi-surface.ts +34 -22
- package/src/pier-options.ts +54 -50
- package/src/pipe-channel.ts +27 -59
- package/src/platform-paths.ts +19 -52
- package/src/{core → plugins}/grid-shape.ts +8 -10
- package/src/{core → plugins}/heat-plan.ts +20 -22
- package/src/plugins/observation.ts +475 -0
- package/src/plugins/subagent.ts +728 -0
- package/src/{core → plugins}/terminal.ts +167 -127
- package/src/{core → plugins}/todo.ts +61 -100
- package/src/progress-core.ts +16 -53
- package/src/reconcile-core.ts +24 -33
- package/src/reducer-core.ts +24 -60
- package/src/reducer-invoker.ts +266 -239
- package/src/renderers.ts +76 -87
- package/src/role-loader.ts +33 -28
- package/src/role-manifest.ts +23 -28
- package/src/role-state.ts +119 -0
- package/src/routing-telemetry.ts +136 -0
- package/src/runtime-policy.ts +25 -40
- package/src/session-tail.ts +65 -41
- package/src/settle-wake-core.ts +52 -21
- package/src/slim-frame.ts +25 -122
- package/src/stale-core.ts +17 -24
- package/src/storage-layout.ts +14 -43
- package/src/subagent-core.ts +253 -118
- package/src/subagent-gc.ts +109 -67
- package/src/subagent-poller.ts +367 -24
- package/src/subagent-session.ts +516 -0
- package/src/subagent-spawn.ts +530 -52
- package/src/swallow.ts +7 -17
- package/src/terminal-core.ts +125 -106
- package/src/todo-core.ts +57 -109
- package/src/todo-read-hook.ts +23 -39
- package/src/todo-reminder-core.ts +26 -51
- package/src/todo-window.ts +11 -12
- package/src/todos-service.ts +15 -35
- package/src/tool-error.ts +6 -13
- package/src/tool-gate.ts +38 -21
- package/src/vocab.ts +52 -25
- package/src/ask-multi.ts +0 -260
- package/src/config-guide.ts +0 -361
- package/src/core/observation.ts +0 -555
- package/src/core/subagent.ts +0 -633
- package/src/efficiency-config-io.ts +0 -53
- package/src/git-adapter.ts +0 -113
- package/src/notice-buffer.ts +0 -23
- package/src/subagent-execute.ts +0 -328
- package/src/subagent-git-io.ts +0 -80
- package/src/subagent-launch.ts +0 -114
- package/src/subagent-list-action.ts +0 -69
- package/src/subagent-output-action.ts +0 -104
- package/src/subagent-output-adapter.ts +0 -27
- package/src/subagent-output-core.ts +0 -263
- package/src/subagent-poll-loop.ts +0 -305
- package/src/subagent-port.ts +0 -27
- package/src/subagent-ready.ts +0 -89
- package/src/subagent-registry.ts +0 -70
- package/src/subagent-resolution.ts +0 -41
- package/src/subagent-scope.ts +0 -41
- package/src/subagent-session-io.ts +0 -230
package/src/reducer-invoker.ts
CHANGED
|
@@ -1,15 +1,11 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* D102 Evidence-Preserving Reducer
|
|
2
|
+
* D102 Evidence-Preserving Reducer invoker: intercepts `bash` diagnostic tool results and replaces
|
|
3
|
+
* the log block with a verified receipt.
|
|
3
4
|
*
|
|
4
|
-
*
|
|
5
|
-
*
|
|
6
|
-
*
|
|
7
|
-
*
|
|
8
|
-
* - Secret pattern detection and project trust verification (security boundaries).
|
|
9
|
-
* - Fast in-process model invocation with timeout protection (default 5s).
|
|
10
|
-
* - Zero-tolerance byte-for-byte quotation validation against original source.
|
|
11
|
-
* - Block-level content replacement (preserves write-lock warnings).
|
|
12
|
-
* - Fail-open resilience: errors or validation failures transparently fallback to full text.
|
|
5
|
+
* Fail-open throughout — every stage that cannot be satisfied (untrusted project, size, credential
|
|
6
|
+
* shape, failed archive, model error, unverifiable quote, no size win) returns undefined and the
|
|
7
|
+
* original text reaches the model unchanged. The swap happens only after a receipt validates
|
|
8
|
+
* byte-for-byte against a source that was archived first.
|
|
13
9
|
*/
|
|
14
10
|
|
|
15
11
|
import type { ExtensionContext } from '@earendil-works/pi-coding-agent';
|
|
@@ -41,7 +37,7 @@ import {
|
|
|
41
37
|
storeContentAddressedObject,
|
|
42
38
|
} from './efficiency-store.ts';
|
|
43
39
|
import type { EvidencePreservingReducerConfig } from './efficiency-config-core.ts';
|
|
44
|
-
import { diagnosticGateRequest, evaluateDiagnosticGate } from './jev-core.ts';
|
|
40
|
+
import { diagnosticGateRequest, evaluateDiagnosticGate, isDiagnosticGateUnanswered } from './jev-core.ts';
|
|
45
41
|
import type { JevRuntime } from './jev-client.ts';
|
|
46
42
|
|
|
47
43
|
/** P0-1 seam: the runtime plus a live confidence gate (config can reload mid-session). */
|
|
@@ -67,43 +63,39 @@ export interface ReducerInvocationResult {
|
|
|
67
63
|
usage?: UsageTotals;
|
|
68
64
|
}
|
|
69
65
|
|
|
66
|
+
/** The subset of pi's AssistantMessage the reducer reads. */
|
|
67
|
+
type ReducerModelOutput = {
|
|
68
|
+
content?: readonly unknown[];
|
|
69
|
+
text?: unknown;
|
|
70
|
+
usage?: UsageLike;
|
|
71
|
+
};
|
|
72
|
+
|
|
73
|
+
type ReducerModel = NonNullable<ExtensionContext['model']>;
|
|
74
|
+
|
|
75
|
+
interface ReducerCandidate {
|
|
76
|
+
command: string;
|
|
77
|
+
block: { type: string; text?: string; [k: string]: unknown };
|
|
78
|
+
text: string;
|
|
79
|
+
fullOutputPath?: string;
|
|
80
|
+
fullOutputSource: 'details' | 'notice' | 'none';
|
|
81
|
+
isTruncated: boolean;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
type ReducerLog = (record: Record<string, unknown>) => Promise<void>;
|
|
85
|
+
|
|
70
86
|
let untrustedWarningEmitted = false;
|
|
71
87
|
|
|
72
|
-
|
|
88
|
+
/** Steps 1–5: the trust boundary sits BEFORE the jev gate — an untrusted project must not send
|
|
89
|
+
* commands to a third-party API even with user-level EPR on, and a refusing host must not be second-guessed. */
|
|
90
|
+
function resolveCandidate(
|
|
73
91
|
event: ToolResultEventLike,
|
|
74
92
|
ctx: ExtensionContext,
|
|
75
93
|
config: EvidencePreservingReducerConfig,
|
|
76
|
-
|
|
77
|
-
): Promise<ReducerInvocationResult | undefined> {
|
|
78
|
-
if (!config.enabled) return undefined;
|
|
79
|
-
const epoch = opts?.epoch ?? 0;
|
|
80
|
-
|
|
81
|
-
// 1. Candidate command & tool gate: strictly bash only
|
|
94
|
+
): ReducerCandidate | undefined {
|
|
82
95
|
if (event.toolName !== 'bash') return undefined;
|
|
83
96
|
const command = typeof event.input?.command === 'string' ? event.input.command.trim() : '';
|
|
84
97
|
if (!command) return undefined;
|
|
85
|
-
if (!isDiagnosticCommand(command)) {
|
|
86
|
-
// P0-1 (RFC docs/rfc-jev-integration.md §3): the regex missed — widen the
|
|
87
|
-
// funnel through jev before giving up. The regex fast path stays
|
|
88
|
-
// authoritative; any jev failure keeps the "not diagnostic" verdict.
|
|
89
|
-
const gate = opts?.jev;
|
|
90
|
-
if (!gate) return undefined;
|
|
91
|
-
const result = await gate.ask(diagnosticGateRequest(command), {
|
|
92
|
-
questionId: 'epr-diagnostic-gate',
|
|
93
|
-
timeoutMs: 1000, // tool-result path; only regex-missed commands reach here
|
|
94
|
-
sessionId: ctx.sessionManager?.getSessionId?.(),
|
|
95
|
-
extra: { site: 'epr-gate', commandSha256: sha256Hex(command) },
|
|
96
|
-
enrich: ({ answers }) => {
|
|
97
|
-
if (!answers) return {};
|
|
98
|
-
const verdict = evaluateDiagnosticGate(answers, gate.getMinConfidence());
|
|
99
|
-
return { verdict: verdict.hit ? 'hit' : verdict.reason, choice: verdict.choice, noul: verdict.noul, confidence: verdict.confidence };
|
|
100
|
-
},
|
|
101
|
-
});
|
|
102
|
-
if (!result.ok) return undefined;
|
|
103
|
-
if (!evaluateDiagnosticGate(result.answers, gate.getMinConfidence()).hit) return undefined;
|
|
104
|
-
}
|
|
105
98
|
|
|
106
|
-
// 2. Project trust boundary check
|
|
107
99
|
const isTrusted =
|
|
108
100
|
typeof (ctx as { isProjectTrusted?: () => boolean }).isProjectTrusted === 'function'
|
|
109
101
|
? (ctx as { isProjectTrusted: () => boolean }).isProjectTrusted()
|
|
@@ -116,209 +108,263 @@ export async function handleReducerToolResult(
|
|
|
116
108
|
return undefined;
|
|
117
109
|
}
|
|
118
110
|
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
111
|
+
const block = event.content.find((b) => b && b.type === 'text' && typeof b.text === 'string');
|
|
112
|
+
if (!block || typeof block.text !== 'string') return undefined;
|
|
113
|
+
const text = block.text;
|
|
122
114
|
|
|
123
|
-
// 4. Retrieve complete untruncated source if available
|
|
124
115
|
const maxChars = config.maxChars ?? DEFAULT_MAX_CHARS;
|
|
125
116
|
const detailPath = typeof event.details?.fullOutputPath === 'string' ? event.details.fullOutputPath : undefined;
|
|
126
|
-
// Pi repeats the path inside the truncation notice that ships with the result text; a replayed
|
|
127
|
-
//
|
|
128
|
-
const noticePath = fullOutputPathFromNotice(
|
|
129
|
-
const fullOutputPath = detailPath ?? noticePath;
|
|
130
|
-
const fullOutputSource = detailPath ? 'details' : noticePath ? 'notice' : 'none';
|
|
117
|
+
// Pi repeats the path inside the truncation notice that ships with the result text; a replayed or
|
|
118
|
+
// re-shaped event can keep only that text, and reducing the preview would lose the evidence.
|
|
119
|
+
const noticePath = fullOutputPathFromNotice(text);
|
|
131
120
|
const isTruncated =
|
|
132
121
|
(event.details?.truncation as { truncated?: boolean } | undefined)?.truncated === true;
|
|
133
122
|
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
// If truncated and no fullOutputPath or fullOutputPath cannot be read safely, fail-open!
|
|
139
|
-
if (isTruncated && !fullOutputPath) {
|
|
140
|
-
if (config.logEnabled && sessionRoot) {
|
|
141
|
-
await logReducerAttempt(sessionRoot, sessionId ?? 'unknown', epoch, {
|
|
142
|
-
commandSha256: sha256Hex(command),
|
|
143
|
-
model: config.model ?? 'default',
|
|
144
|
-
verificationOk: false,
|
|
145
|
-
reason: 'truncated-source',
|
|
146
|
-
action: 'fallback_full_text',
|
|
147
|
-
fullOutputSource,
|
|
148
|
-
});
|
|
149
|
-
}
|
|
123
|
+
// Cheap reducibility precondition: a truncated output is presumed large (that is why pi truncated
|
|
124
|
+
// it); anything else must clear minBytes/maxChars on the preview, which for non-truncated results
|
|
125
|
+
// IS the full body. Commands below this line never pay for the gate call or the full read.
|
|
126
|
+
if (!isTruncated && (Buffer.byteLength(text, 'utf8') < (config.minBytes ?? DEFAULT_MIN_BYTES) || text.length > maxChars)) {
|
|
150
127
|
return undefined;
|
|
151
128
|
}
|
|
152
129
|
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
action: 'fallback_full_text',
|
|
164
|
-
fullOutputSource,
|
|
165
|
-
});
|
|
166
|
-
}
|
|
167
|
-
// Log was truncated but full file could not be read safely -> fallback to avoid hallucinated receipts
|
|
168
|
-
return undefined;
|
|
169
|
-
}
|
|
170
|
-
body = full.content;
|
|
171
|
-
}
|
|
172
|
-
|
|
173
|
-
const sourceBytes = Buffer.byteLength(body, 'utf8');
|
|
174
|
-
const minBytes = config.minBytes ?? DEFAULT_MIN_BYTES;
|
|
175
|
-
if (sourceBytes < minBytes || body.length > maxChars) return undefined;
|
|
130
|
+
const fullOutputPath = detailPath ?? noticePath;
|
|
131
|
+
return {
|
|
132
|
+
command,
|
|
133
|
+
block,
|
|
134
|
+
text,
|
|
135
|
+
...(fullOutputPath ? { fullOutputPath } : {}),
|
|
136
|
+
fullOutputSource: detailPath ? 'details' : noticePath ? 'notice' : 'none',
|
|
137
|
+
isTruncated,
|
|
138
|
+
};
|
|
139
|
+
}
|
|
176
140
|
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
141
|
+
/**
|
|
142
|
+
* Step 6, jev first (RFC §3 P0-1): a confident jev rejection is authoritative even for regex-listed
|
|
143
|
+
* commands; the regex list decides only when jev is absent, fails or answers below the confidence
|
|
144
|
+
* gate (credential-shaped command lines stay local).
|
|
145
|
+
*/
|
|
146
|
+
async function isDiagnosticCandidate(
|
|
147
|
+
command: string,
|
|
148
|
+
gate: JevEprGateDependency | undefined,
|
|
149
|
+
sessionId: string | undefined,
|
|
150
|
+
): Promise<boolean> {
|
|
151
|
+
const regexHit = isDiagnosticCommand(command);
|
|
152
|
+
if (!gate || containsLikelySecret(command)) return regexHit;
|
|
153
|
+
const result = await gate.ask(diagnosticGateRequest(command), {
|
|
154
|
+
questionId: 'epr-diagnostic-gate',
|
|
155
|
+
timeoutMs: 2500, // cold TLS hit 1002ms at 1s; at 1500ms a real session still saw 14/73 calls (19%) time out — 2.5s keeps the gate decisive without stalling reducers
|
|
156
|
+
sessionId,
|
|
157
|
+
// regex_hit makes the flip observable: how often and in which direction jev overrides the regex list (jev.jsonl).
|
|
158
|
+
extra: { site: 'epr-gate', commandSha256: sha256Hex(command), regexHit },
|
|
159
|
+
enrich: ({ answers }) => {
|
|
160
|
+
if (!answers) return {};
|
|
161
|
+
const verdict = evaluateDiagnosticGate(answers, gate.getMinConfidence());
|
|
162
|
+
return {
|
|
163
|
+
verdict: verdict.hit ? 'hit' : verdict.reason,
|
|
164
|
+
choice: verdict.choice,
|
|
165
|
+
noul: verdict.noul,
|
|
166
|
+
confidence: verdict.confidence,
|
|
167
|
+
};
|
|
168
|
+
},
|
|
169
|
+
});
|
|
170
|
+
if (!result.ok) return regexHit;
|
|
171
|
+
const verdict = evaluateDiagnosticGate(result.answers, gate.getMinConfidence());
|
|
172
|
+
if (verdict.hit) return true;
|
|
173
|
+
return isDiagnosticGateUnanswered(verdict) ? regexHit : false;
|
|
174
|
+
}
|
|
194
175
|
|
|
195
|
-
|
|
176
|
+
/** Step 7: the untruncated source; both failure modes write the same `truncated-source` evidence
|
|
177
|
+
* row, which lands only for candidates that got here — gate-rejected commands never read a file. */
|
|
178
|
+
async function readSource(
|
|
179
|
+
candidate: ReducerCandidate,
|
|
180
|
+
config: EvidencePreservingReducerConfig,
|
|
181
|
+
log: ReducerLog,
|
|
182
|
+
): Promise<string | null> {
|
|
183
|
+
const truncatedRow = (): Promise<void> =>
|
|
184
|
+
log({
|
|
185
|
+
model: config.model ?? 'default',
|
|
186
|
+
verificationOk: false,
|
|
187
|
+
reason: 'truncated-source',
|
|
188
|
+
action: 'fallback_full_text',
|
|
189
|
+
fullOutputSource: candidate.fullOutputSource,
|
|
190
|
+
});
|
|
196
191
|
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
192
|
+
if (candidate.isTruncated && !candidate.fullOutputPath) {
|
|
193
|
+
await truncatedRow();
|
|
194
|
+
return null; // Truncated with no recoverable full log: fail open rather than reduce the preview.
|
|
195
|
+
}
|
|
196
|
+
if (!candidate.fullOutputPath) return candidate.text;
|
|
197
|
+
const full = await readBashFullOutput(candidate.fullOutputPath, config.maxChars ?? DEFAULT_MAX_CHARS);
|
|
198
|
+
if (!full) {
|
|
199
|
+
await truncatedRow();
|
|
200
|
+
return null;
|
|
201
|
+
}
|
|
202
|
+
return full.content;
|
|
203
|
+
}
|
|
200
204
|
|
|
201
|
-
|
|
205
|
+
/** Steps 7/P0: the source archive is mandatory before any modification; a failed write fails open. */
|
|
206
|
+
async function archiveSource(sessionRoot: string, body: string): Promise<string | null> {
|
|
207
|
+
const path = reducerObjectPath(sessionRoot, sha256Hex(body));
|
|
202
208
|
try {
|
|
203
|
-
await storeContentAddressedObject(
|
|
209
|
+
await storeContentAddressedObject(path, body);
|
|
204
210
|
} catch {
|
|
205
|
-
return
|
|
211
|
+
return null;
|
|
206
212
|
}
|
|
213
|
+
return path;
|
|
214
|
+
}
|
|
207
215
|
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
let
|
|
214
|
-
let reducerModelSource: 'configured' | 'session-fallback' = 'configured';
|
|
216
|
+
/** Step 8: the configured cheap reducer first, the session model as fallback. */
|
|
217
|
+
function resolveReducerModel(
|
|
218
|
+
ctx: ExtensionContext,
|
|
219
|
+
config: EvidencePreservingReducerConfig,
|
|
220
|
+
): { model: ReducerModel; label: string; source: 'configured' | 'session-fallback' } | undefined {
|
|
221
|
+
let model: ReducerModel | undefined;
|
|
215
222
|
if (config.model && typeof config.model === 'string' && config.model.includes('/')) {
|
|
216
223
|
const [provider, ...rest] = config.model.split('/');
|
|
217
|
-
const modelId = rest.join('/');
|
|
218
224
|
try {
|
|
219
|
-
|
|
225
|
+
model = ctx.modelRegistry?.find?.(provider, rest.join('/'));
|
|
220
226
|
} catch {
|
|
221
|
-
|
|
227
|
+
model = undefined;
|
|
222
228
|
}
|
|
223
229
|
}
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
// Unified provider/id label: the 2026-09-17 logs split one session into
|
|
232
|
-
// "cliproxy/gemini-3.8-flash-high" (fallback rows) and "gemini-3.8-flash-high"
|
|
233
|
-
// (applied rows), fragmenting per-model grouping.
|
|
234
|
-
const reducerModelLabel =
|
|
235
|
-
typeof targetModel.provider === 'string' && typeof targetModel.id === 'string'
|
|
236
|
-
? `${targetModel.provider}/${targetModel.id}`
|
|
237
|
-
: (config.model ?? 'unknown');
|
|
238
|
-
|
|
239
|
-
// 8. Invoke model in-process with timeout
|
|
240
|
-
const timeoutMs = config.timeoutMs ?? DEFAULT_TIMEOUT_MS;
|
|
241
|
-
const timeoutSignal = AbortSignal.timeout(timeoutMs);
|
|
242
|
-
const signal =
|
|
243
|
-
ctx.signal && typeof (AbortSignal as any).any === 'function'
|
|
244
|
-
? (AbortSignal as any).any([ctx.signal, timeoutSignal])
|
|
245
|
-
: timeoutSignal;
|
|
246
|
-
|
|
247
|
-
const promptInput = reducerInputPrompt({
|
|
248
|
-
command,
|
|
249
|
-
isError: event.isError,
|
|
250
|
-
sourceHash,
|
|
251
|
-
sourceBytes,
|
|
252
|
-
sourceLines,
|
|
253
|
-
body,
|
|
254
|
-
});
|
|
230
|
+
const source = model ? 'configured' : 'session-fallback';
|
|
231
|
+
if (!model) model = ctx.model;
|
|
232
|
+
if (!model || typeof ctx.modelRegistry?.complete !== 'function') return undefined;
|
|
233
|
+
const label =
|
|
234
|
+
model.provider && model.id ? `${model.provider}/${model.id}` : (config.model ?? 'unknown');
|
|
235
|
+
return { model, label, source };
|
|
236
|
+
}
|
|
255
237
|
|
|
238
|
+
/** Step 9: in-process model call under a total-latency budget through pi's own completion, so the
|
|
239
|
+
* reducer runs on the session's provider auth; failure text is returned for telemetry. */
|
|
240
|
+
async function invokeReducer(
|
|
241
|
+
ctx: ExtensionContext,
|
|
242
|
+
config: EvidencePreservingReducerConfig,
|
|
243
|
+
model: ReducerModel,
|
|
244
|
+
promptInput: string,
|
|
245
|
+
): Promise<{ output: ReducerModelOutput; durationMs: number } | { error: string; durationMs: number }> {
|
|
246
|
+
const timeoutSignal = AbortSignal.timeout(config.timeoutMs ?? DEFAULT_TIMEOUT_MS);
|
|
247
|
+
const signal = ctx.signal ? AbortSignal.any([ctx.signal, timeoutSignal]) : timeoutSignal;
|
|
256
248
|
const startMs = Date.now();
|
|
257
|
-
let modelResult: any;
|
|
258
249
|
try {
|
|
259
|
-
|
|
260
|
-
|
|
250
|
+
const output: ReducerModelOutput = await ctx.modelRegistry.complete(
|
|
251
|
+
model,
|
|
261
252
|
{
|
|
262
253
|
systemPrompt: reducerInstructions(),
|
|
263
254
|
messages: [{ role: 'user', content: [{ type: 'text', text: promptInput }], timestamp: Date.now() }],
|
|
264
255
|
},
|
|
265
|
-
{
|
|
266
|
-
maxTokens: config.maxOutputTokens ?? DEFAULT_MAX_OUTPUT_TOKENS,
|
|
267
|
-
cacheRetention: 'none',
|
|
268
|
-
signal,
|
|
269
|
-
},
|
|
256
|
+
{ maxTokens: config.maxOutputTokens ?? DEFAULT_MAX_OUTPUT_TOKENS, cacheRetention: 'none', signal },
|
|
270
257
|
);
|
|
258
|
+
return { output, durationMs: Date.now() - startMs };
|
|
271
259
|
} catch (err) {
|
|
272
|
-
|
|
273
|
-
// (timeout / provider error) with no jsonl trace at all.
|
|
274
|
-
if (config.logEnabled) {
|
|
275
|
-
await logReducerAttempt(sessionRoot, sessionId ?? 'unknown', epoch, {
|
|
276
|
-
commandSha256: sha256Hex(command),
|
|
277
|
-
sourceBytes,
|
|
278
|
-
model: reducerModelLabel,
|
|
279
|
-
reducerModelSource,
|
|
280
|
-
verificationOk: false,
|
|
281
|
-
reason: 'invoke-failed',
|
|
282
|
-
action: 'fallback_full_text',
|
|
283
|
-
error: err instanceof Error ? err.message : String(err),
|
|
284
|
-
durationMs: Date.now() - startMs,
|
|
285
|
-
});
|
|
286
|
-
}
|
|
287
|
-
return undefined; // Fail-open on timeout or provider error
|
|
260
|
+
return { error: err instanceof Error ? err.message : String(err), durationMs: Date.now() - startMs };
|
|
288
261
|
}
|
|
262
|
+
}
|
|
289
263
|
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
264
|
+
export async function handleReducerToolResult(
|
|
265
|
+
event: ToolResultEventLike,
|
|
266
|
+
ctx: ExtensionContext,
|
|
267
|
+
config: EvidencePreservingReducerConfig,
|
|
268
|
+
opts?: { epoch?: number; jev?: JevEprGateDependency },
|
|
269
|
+
): Promise<ReducerInvocationResult | undefined> {
|
|
270
|
+
if (!config.enabled) return undefined;
|
|
271
|
+
const epoch = opts?.epoch ?? 0;
|
|
295
272
|
|
|
273
|
+
const candidate = resolveCandidate(event, ctx, config);
|
|
274
|
+
if (!candidate) return undefined;
|
|
275
|
+
const { command, block } = candidate;
|
|
276
|
+
const minBytes = config.minBytes ?? DEFAULT_MIN_BYTES;
|
|
277
|
+
const maxChars = config.maxChars ?? DEFAULT_MAX_CHARS;
|
|
278
|
+
|
|
279
|
+
const sessionId = ctx.sessionManager?.getSessionId?.();
|
|
280
|
+
const sessionRoot = resolveSessionRoot(ctx.sessionManager?.getSessionDir?.(), sessionId);
|
|
281
|
+
if (!sessionRoot) return undefined;
|
|
282
|
+
|
|
283
|
+
if (!(await isDiagnosticCandidate(command, opts?.jev, sessionId))) return undefined;
|
|
284
|
+
|
|
285
|
+
const log: ReducerLog = async (record) => {
|
|
286
|
+
if (!config.logEnabled) return;
|
|
287
|
+
await logReducerAttempt(sessionRoot, sessionId ?? 'unknown', epoch, {
|
|
288
|
+
commandSha256: sha256Hex(command),
|
|
289
|
+
...record,
|
|
290
|
+
});
|
|
291
|
+
};
|
|
292
|
+
|
|
293
|
+
const body = await readSource(candidate, config, log);
|
|
294
|
+
if (body === null) return undefined;
|
|
295
|
+
|
|
296
|
+
// Step 8: size gates on the resolved body (a truncated output's true size is only known here).
|
|
297
|
+
const sourceBytes = Buffer.byteLength(body, 'utf8');
|
|
298
|
+
if (sourceBytes < minBytes || body.length > maxChars) return undefined;
|
|
299
|
+
|
|
300
|
+
// Step 9: credential heuristic; `secretSnippet` names the shape that tripped the gate.
|
|
301
|
+
if (containsLikelySecret(body)) {
|
|
302
|
+
await log({
|
|
303
|
+
sourceBytes,
|
|
304
|
+
model: config.model ?? 'default',
|
|
305
|
+
verificationOk: false,
|
|
306
|
+
reason: 'likely-secret',
|
|
307
|
+
action: 'fallback_full_text',
|
|
308
|
+
secretSnippet: extractLikelySecretMatch(body),
|
|
309
|
+
});
|
|
310
|
+
return undefined;
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
const sourceHash = sha256Hex(body);
|
|
314
|
+
const sourceLines = countLines(body);
|
|
315
|
+
const archivePath = await archiveSource(sessionRoot, body);
|
|
316
|
+
if (archivePath === null) return undefined;
|
|
317
|
+
if (config.localOnly) return undefined; // Archival is the whole job in local-only mode.
|
|
318
|
+
|
|
319
|
+
const reducer = resolveReducerModel(ctx, config);
|
|
320
|
+
if (!reducer) return undefined;
|
|
321
|
+
/** Evidence row for a fail-open exit taken after the reducer model was resolved. */
|
|
322
|
+
const fallbackRow = (reason: string, durationMs: number, extra: Record<string, unknown> = {}): Promise<void> =>
|
|
323
|
+
log({
|
|
324
|
+
sourceBytes,
|
|
325
|
+
model: reducer.label,
|
|
326
|
+
reducerModelSource: reducer.source,
|
|
327
|
+
verificationOk: false,
|
|
328
|
+
reason,
|
|
329
|
+
action: 'fallback_full_text',
|
|
330
|
+
durationMs,
|
|
331
|
+
...extra,
|
|
332
|
+
});
|
|
333
|
+
|
|
334
|
+
const invocation = await invokeReducer(
|
|
335
|
+
ctx,
|
|
336
|
+
config,
|
|
337
|
+
reducer.model,
|
|
338
|
+
reducerInputPrompt({ command, isError: event.isError, sourceHash, sourceBytes, sourceLines, body }),
|
|
339
|
+
);
|
|
340
|
+
if ('error' in invocation) {
|
|
341
|
+
await fallbackRow('invoke-failed', invocation.durationMs, { error: invocation.error });
|
|
342
|
+
return undefined;
|
|
343
|
+
}
|
|
344
|
+
const { output, durationMs } = invocation;
|
|
345
|
+
|
|
346
|
+
const blocks: readonly unknown[] | undefined = Array.isArray(output.content) ? output.content : undefined;
|
|
347
|
+
const modelOutput = blocks
|
|
348
|
+
? blocks
|
|
349
|
+
.map((block) => {
|
|
350
|
+
const text = (block as { text?: unknown }).text;
|
|
351
|
+
return typeof text === 'string' ? text : '';
|
|
352
|
+
})
|
|
353
|
+
.join('')
|
|
354
|
+
: typeof output.text === 'string'
|
|
355
|
+
? output.text
|
|
356
|
+
: '';
|
|
296
357
|
if (!modelOutput.trim()) return undefined;
|
|
297
358
|
|
|
298
|
-
// 9. Strict byte-for-byte quotation validation
|
|
299
359
|
const validated = validateReceipt(modelOutput, sourceHash, body, event.isError);
|
|
300
360
|
if (!validated.ok) {
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
commandSha256: sha256Hex(command),
|
|
305
|
-
sourceBytes,
|
|
306
|
-
model: reducerModelLabel,
|
|
307
|
-
reducerModelSource,
|
|
308
|
-
verificationOk: false,
|
|
309
|
-
reason: failReason,
|
|
310
|
-
action: 'fallback_full_text',
|
|
311
|
-
// invalid-json rows carried no evidence of what the model actually
|
|
312
|
-
// returned (fences? truncation? prose?) — keep a sanitized head.
|
|
313
|
-
...(failReason === 'invalid-json' ? { rawOutputHead: JSON.stringify(modelOutput.slice(0, 200)) } : {}),
|
|
314
|
-
durationMs: Date.now() - startMs,
|
|
315
|
-
});
|
|
316
|
-
}
|
|
361
|
+
// Keep a sanitized head: invalid-json rows must show what the model actually returned.
|
|
362
|
+
const head = validated.reason === 'invalid-json' ? { rawOutputHead: JSON.stringify(modelOutput.slice(0, 200)) } : {};
|
|
363
|
+
await fallbackRow(validated.reason, durationMs, head);
|
|
317
364
|
return undefined;
|
|
318
365
|
}
|
|
319
366
|
|
|
320
|
-
|
|
321
|
-
const providerName = typeof targetModel.provider === 'string' ? targetModel.provider : undefined;
|
|
367
|
+
// Step 11: block-level replacement — only the log block, so write-lock warnings survive.
|
|
322
368
|
const receipt = formatReceiptText({
|
|
323
369
|
command,
|
|
324
370
|
sourceHash,
|
|
@@ -326,47 +372,30 @@ export async function handleReducerToolResult(
|
|
|
326
372
|
sourceLines,
|
|
327
373
|
sourceArtifactPath: archivePath,
|
|
328
374
|
validated: validated.value,
|
|
329
|
-
model:
|
|
330
|
-
provider:
|
|
331
|
-
totalTokens:
|
|
375
|
+
model: reducer.model.id ?? config.model ?? 'default',
|
|
376
|
+
provider: typeof reducer.model.provider === 'string' ? reducer.model.provider : undefined,
|
|
377
|
+
totalTokens: output.usage?.totalTokens,
|
|
332
378
|
});
|
|
333
|
-
|
|
334
379
|
const receiptBytes = Buffer.byteLength(receipt, 'utf8');
|
|
335
|
-
if (receiptBytes >= sourceBytes)
|
|
336
|
-
return undefined; // Must be smaller
|
|
337
|
-
}
|
|
338
|
-
|
|
339
|
-
// 10. Block-level replacement: only replace the log text block, preserving warnings
|
|
340
|
-
const nextContent = event.content.map((b): { type: 'text'; text: string } =>
|
|
341
|
-
(b === logBlock ? { ...b, text: receipt } : b) as { type: 'text'; text: string });
|
|
342
|
-
|
|
343
|
-
// Backfill usage tokens: the nested reducer model call plus whatever the tool result already carried.
|
|
344
|
-
// MUST stay a COMPLETE pi `Usage`: pi persists this onto the tool-result message and its footer
|
|
345
|
-
// renders it through `addUsageToTotals`, which reads `usage.cost.total` unguarded. Emitting only
|
|
346
|
-
// { input, output, totalTokens } killed the whole pi process with
|
|
347
|
-
// "TypeError: Cannot read properties of undefined (reading 'total')" (observed 2026-09-13 in a
|
|
348
|
-
// subagent pane, stack: FooterComponent.render -> addUsageToTotals). See docs/session-format.md.
|
|
349
|
-
const nextUsage = mergeUsage(event.usage, modelResult?.usage);
|
|
380
|
+
if (receiptBytes >= sourceBytes) return undefined;
|
|
350
381
|
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
durationMs: Date.now() - startMs,
|
|
364
|
-
});
|
|
365
|
-
}
|
|
382
|
+
await log({
|
|
383
|
+
sourceBytes,
|
|
384
|
+
receiptBytes,
|
|
385
|
+
grossSavedBytes: sourceBytes - receiptBytes,
|
|
386
|
+
compressionRatio: Number((receiptBytes / sourceBytes).toFixed(3)),
|
|
387
|
+
model: reducer.label,
|
|
388
|
+
reducerModelSource: reducer.source,
|
|
389
|
+
verificationOk: true,
|
|
390
|
+
action: 'applied',
|
|
391
|
+
fullOutputSource: candidate.fullOutputSource,
|
|
392
|
+
durationMs,
|
|
393
|
+
});
|
|
366
394
|
|
|
367
395
|
return {
|
|
368
|
-
content:
|
|
369
|
-
|
|
396
|
+
content: event.content.map((b): { type: 'text'; text: string } =>
|
|
397
|
+
(b === block ? { ...b, text: receipt } : b) as { type: 'text'; text: string }),
|
|
398
|
+
usage: mergeUsage(event.usage, output.usage),
|
|
370
399
|
};
|
|
371
400
|
}
|
|
372
401
|
|
|
@@ -376,9 +405,8 @@ async function logReducerAttempt(
|
|
|
376
405
|
epoch: number,
|
|
377
406
|
record: Record<string, unknown>,
|
|
378
407
|
): Promise<void> {
|
|
379
|
-
const logPath = efficiencyLogPath(sessionRoot, 'reducer');
|
|
380
408
|
try {
|
|
381
|
-
await appendEfficiencyLog(
|
|
409
|
+
await appendEfficiencyLog(efficiencyLogPath(sessionRoot, 'reducer'), {
|
|
382
410
|
schema: 'pier-efficiency/1',
|
|
383
411
|
mechanism: 'evidencePreservingReducer',
|
|
384
412
|
ts: new Date().toISOString(),
|
|
@@ -387,6 +415,5 @@ async function logReducerAttempt(
|
|
|
387
415
|
...record,
|
|
388
416
|
});
|
|
389
417
|
} catch {
|
|
390
|
-
/* ignore logging error */
|
|
391
418
|
}
|
|
392
419
|
}
|