pi-pier 0.2.7 → 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 +26 -12
- package/package.json +8 -3
- package/schemas/efficiency-config.schema.json +77 -0
- package/schemas/role-manifest.schema.json +7 -1
- package/skills/pi-herdr-workflow/SKILL.md +11 -0
- package/src/ansi-text.ts +134 -0
- package/src/ask-dialog.ts +308 -0
- package/src/ask-user.ts +139 -88
- package/src/bootstrap.ts +13 -14
- package/src/compact-coordinator.ts +471 -0
- package/src/compact-economics-core.ts +328 -0
- package/src/config-catalog-core.ts +332 -0
- package/src/config-command.ts +388 -0
- package/src/dashboard-command.ts +108 -0
- package/src/efficiency-config-core.ts +464 -0
- package/src/efficiency-store.ts +299 -0
- package/src/focus-poller.ts +223 -0
- package/src/gc-core.ts +77 -18
- package/src/herdr-client.ts +284 -154
- package/src/history-store.ts +36 -23
- package/src/index-gates.ts +124 -0
- package/src/index-locks.ts +29 -16
- package/src/index-master.ts +29 -19
- package/src/index-notices.ts +35 -11
- package/src/index-pipe.ts +17 -7
- package/src/index-roles.ts +306 -0
- package/src/index-runtime.ts +2 -6
- package/src/index-worker.ts +7 -9
- package/src/index.ts +425 -388
- package/src/jev-client.ts +141 -0
- package/src/jev-core.ts +431 -0
- package/src/ledger.ts +13 -21
- package/src/lock-core.ts +98 -37
- package/src/manifest-compose.ts +35 -20
- package/src/observation-core.ts +200 -0
- package/src/pane-title.ts +19 -38
- package/src/pi-surface.ts +34 -22
- package/src/pier-options.ts +100 -0
- package/src/pipe-channel.ts +65 -52
- package/src/platform-paths.ts +19 -52
- package/src/{core → plugins}/grid-shape.ts +12 -12
- package/src/plugins/heat-plan.ts +158 -0
- package/src/plugins/observation.ts +475 -0
- package/src/plugins/subagent.ts +728 -0
- package/src/{core → plugins}/terminal.ts +237 -123
- package/src/{core → plugins}/todo.ts +75 -100
- package/src/progress-core.ts +16 -53
- package/src/reconcile-core.ts +24 -33
- package/src/reducer-core.ts +322 -0
- package/src/reducer-invoker.ts +419 -0
- package/src/renderers.ts +187 -0
- package/src/role-loader.ts +35 -29
- package/src/role-manifest.ts +23 -32
- package/src/role-state.ts +119 -0
- package/src/routing-telemetry.ts +136 -0
- package/src/runtime-policy.ts +25 -43
- package/src/session-tail.ts +102 -23
- package/src/settle-wake-core.ts +55 -22
- package/src/slim-frame.ts +29 -124
- package/src/stale-core.ts +17 -24
- package/src/storage-layout.ts +24 -30
- package/src/subagent-core.ts +256 -119
- package/src/subagent-gc.ts +131 -69
- package/src/subagent-poller.ts +401 -12
- package/src/subagent-session.ts +516 -0
- package/src/subagent-spawn.ts +651 -28
- package/src/swallow.ts +65 -0
- package/src/terminal-core.ts +160 -69
- package/src/todo-core.ts +62 -113
- package/src/todo-read-hook.ts +23 -39
- package/src/todo-reminder-core.ts +30 -48
- package/src/todo-window.ts +11 -12
- package/src/todos-service.ts +27 -35
- package/src/tool-error.ts +19 -0
- package/src/tool-gate.ts +38 -21
- package/src/vocab.ts +55 -25
- package/src/core/subagent.ts +0 -839
- package/src/git-adapter.ts +0 -104
- package/src/notice-buffer.ts +0 -23
- package/src/subagent-git-io.ts +0 -80
- package/src/subagent-launch.ts +0 -110
- package/src/subagent-poll-loop.ts +0 -285
- package/src/subagent-port.ts +0 -27
- package/src/subagent-scope.ts +0 -39
- package/src/subagent-session-io.ts +0 -147
|
@@ -0,0 +1,419 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* D102 Evidence-Preserving Reducer invoker: intercepts `bash` diagnostic tool results and replaces
|
|
3
|
+
* the log block with a verified receipt.
|
|
4
|
+
*
|
|
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.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import type { ExtensionContext } from '@earendil-works/pi-coding-agent';
|
|
12
|
+
import { countLines } from './observation-core.ts';
|
|
13
|
+
import {
|
|
14
|
+
containsLikelySecret,
|
|
15
|
+
DEFAULT_MAX_CHARS,
|
|
16
|
+
DEFAULT_MAX_OUTPUT_TOKENS,
|
|
17
|
+
DEFAULT_MIN_BYTES,
|
|
18
|
+
DEFAULT_TIMEOUT_MS,
|
|
19
|
+
extractLikelySecretMatch,
|
|
20
|
+
formatReceiptText,
|
|
21
|
+
fullOutputPathFromNotice,
|
|
22
|
+
isDiagnosticCommand,
|
|
23
|
+
mergeUsage,
|
|
24
|
+
reducerInputPrompt,
|
|
25
|
+
reducerInstructions,
|
|
26
|
+
sha256Hex,
|
|
27
|
+
validateReceipt,
|
|
28
|
+
type UsageLike,
|
|
29
|
+
type UsageTotals,
|
|
30
|
+
} from './reducer-core.ts';
|
|
31
|
+
import {
|
|
32
|
+
appendEfficiencyLog,
|
|
33
|
+
efficiencyLogPath,
|
|
34
|
+
readBashFullOutput,
|
|
35
|
+
reducerObjectPath,
|
|
36
|
+
resolveSessionRoot,
|
|
37
|
+
storeContentAddressedObject,
|
|
38
|
+
} from './efficiency-store.ts';
|
|
39
|
+
import type { EvidencePreservingReducerConfig } from './efficiency-config-core.ts';
|
|
40
|
+
import { diagnosticGateRequest, evaluateDiagnosticGate, isDiagnosticGateUnanswered } from './jev-core.ts';
|
|
41
|
+
import type { JevRuntime } from './jev-client.ts';
|
|
42
|
+
|
|
43
|
+
/** P0-1 seam: the runtime plus a live confidence gate (config can reload mid-session). */
|
|
44
|
+
export interface JevEprGateDependency {
|
|
45
|
+
ask: JevRuntime['ask'];
|
|
46
|
+
getMinConfidence: () => number;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export interface ToolResultEventLike {
|
|
50
|
+
toolName: string;
|
|
51
|
+
toolCallId: string;
|
|
52
|
+
input?: Record<string, unknown>;
|
|
53
|
+
content: Array<{ type: string; text?: string; [k: string]: unknown }>;
|
|
54
|
+
details?: Record<string, unknown>;
|
|
55
|
+
isError: boolean;
|
|
56
|
+
usage?: UsageLike;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
export interface ReducerInvocationResult {
|
|
60
|
+
/** pi's ToolResultEventResult.content uses TextContent, so the literal type must stay narrow. */
|
|
61
|
+
content?: Array<{ type: 'text'; text: string; [k: string]: unknown }>;
|
|
62
|
+
/** Complete pi `Usage` (see mergeUsage): a partial object crashes pi's footer renderer. */
|
|
63
|
+
usage?: UsageTotals;
|
|
64
|
+
}
|
|
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
|
+
|
|
86
|
+
let untrustedWarningEmitted = false;
|
|
87
|
+
|
|
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(
|
|
91
|
+
event: ToolResultEventLike,
|
|
92
|
+
ctx: ExtensionContext,
|
|
93
|
+
config: EvidencePreservingReducerConfig,
|
|
94
|
+
): ReducerCandidate | undefined {
|
|
95
|
+
if (event.toolName !== 'bash') return undefined;
|
|
96
|
+
const command = typeof event.input?.command === 'string' ? event.input.command.trim() : '';
|
|
97
|
+
if (!command) return undefined;
|
|
98
|
+
|
|
99
|
+
const isTrusted =
|
|
100
|
+
typeof (ctx as { isProjectTrusted?: () => boolean }).isProjectTrusted === 'function'
|
|
101
|
+
? (ctx as { isProjectTrusted: () => boolean }).isProjectTrusted()
|
|
102
|
+
: false;
|
|
103
|
+
if (!isTrusted) {
|
|
104
|
+
if (!untrustedWarningEmitted) {
|
|
105
|
+
untrustedWarningEmitted = true;
|
|
106
|
+
console.warn('[pi-herdr] EPR 提炼已在未受信任项目中被安全禁用');
|
|
107
|
+
}
|
|
108
|
+
return undefined;
|
|
109
|
+
}
|
|
110
|
+
|
|
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;
|
|
114
|
+
|
|
115
|
+
const maxChars = config.maxChars ?? DEFAULT_MAX_CHARS;
|
|
116
|
+
const detailPath = typeof event.details?.fullOutputPath === 'string' ? event.details.fullOutputPath : undefined;
|
|
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);
|
|
120
|
+
const isTruncated =
|
|
121
|
+
(event.details?.truncation as { truncated?: boolean } | undefined)?.truncated === true;
|
|
122
|
+
|
|
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)) {
|
|
127
|
+
return undefined;
|
|
128
|
+
}
|
|
129
|
+
|
|
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
|
+
}
|
|
140
|
+
|
|
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
|
+
}
|
|
175
|
+
|
|
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
|
+
});
|
|
191
|
+
|
|
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
|
+
}
|
|
204
|
+
|
|
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));
|
|
208
|
+
try {
|
|
209
|
+
await storeContentAddressedObject(path, body);
|
|
210
|
+
} catch {
|
|
211
|
+
return null;
|
|
212
|
+
}
|
|
213
|
+
return path;
|
|
214
|
+
}
|
|
215
|
+
|
|
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;
|
|
222
|
+
if (config.model && typeof config.model === 'string' && config.model.includes('/')) {
|
|
223
|
+
const [provider, ...rest] = config.model.split('/');
|
|
224
|
+
try {
|
|
225
|
+
model = ctx.modelRegistry?.find?.(provider, rest.join('/'));
|
|
226
|
+
} catch {
|
|
227
|
+
model = undefined;
|
|
228
|
+
}
|
|
229
|
+
}
|
|
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
|
+
}
|
|
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;
|
|
248
|
+
const startMs = Date.now();
|
|
249
|
+
try {
|
|
250
|
+
const output: ReducerModelOutput = await ctx.modelRegistry.complete(
|
|
251
|
+
model,
|
|
252
|
+
{
|
|
253
|
+
systemPrompt: reducerInstructions(),
|
|
254
|
+
messages: [{ role: 'user', content: [{ type: 'text', text: promptInput }], timestamp: Date.now() }],
|
|
255
|
+
},
|
|
256
|
+
{ maxTokens: config.maxOutputTokens ?? DEFAULT_MAX_OUTPUT_TOKENS, cacheRetention: 'none', signal },
|
|
257
|
+
);
|
|
258
|
+
return { output, durationMs: Date.now() - startMs };
|
|
259
|
+
} catch (err) {
|
|
260
|
+
return { error: err instanceof Error ? err.message : String(err), durationMs: Date.now() - startMs };
|
|
261
|
+
}
|
|
262
|
+
}
|
|
263
|
+
|
|
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;
|
|
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
|
+
: '';
|
|
357
|
+
if (!modelOutput.trim()) return undefined;
|
|
358
|
+
|
|
359
|
+
const validated = validateReceipt(modelOutput, sourceHash, body, event.isError);
|
|
360
|
+
if (!validated.ok) {
|
|
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);
|
|
364
|
+
return undefined;
|
|
365
|
+
}
|
|
366
|
+
|
|
367
|
+
// Step 11: block-level replacement — only the log block, so write-lock warnings survive.
|
|
368
|
+
const receipt = formatReceiptText({
|
|
369
|
+
command,
|
|
370
|
+
sourceHash,
|
|
371
|
+
sourceBytes,
|
|
372
|
+
sourceLines,
|
|
373
|
+
sourceArtifactPath: archivePath,
|
|
374
|
+
validated: validated.value,
|
|
375
|
+
model: reducer.model.id ?? config.model ?? 'default',
|
|
376
|
+
provider: typeof reducer.model.provider === 'string' ? reducer.model.provider : undefined,
|
|
377
|
+
totalTokens: output.usage?.totalTokens,
|
|
378
|
+
});
|
|
379
|
+
const receiptBytes = Buffer.byteLength(receipt, 'utf8');
|
|
380
|
+
if (receiptBytes >= sourceBytes) return undefined;
|
|
381
|
+
|
|
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
|
+
});
|
|
394
|
+
|
|
395
|
+
return {
|
|
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),
|
|
399
|
+
};
|
|
400
|
+
}
|
|
401
|
+
|
|
402
|
+
async function logReducerAttempt(
|
|
403
|
+
sessionRoot: string,
|
|
404
|
+
sessionId: string,
|
|
405
|
+
epoch: number,
|
|
406
|
+
record: Record<string, unknown>,
|
|
407
|
+
): Promise<void> {
|
|
408
|
+
try {
|
|
409
|
+
await appendEfficiencyLog(efficiencyLogPath(sessionRoot, 'reducer'), {
|
|
410
|
+
schema: 'pier-efficiency/1',
|
|
411
|
+
mechanism: 'evidencePreservingReducer',
|
|
412
|
+
ts: new Date().toISOString(),
|
|
413
|
+
sessionId,
|
|
414
|
+
epoch,
|
|
415
|
+
...record,
|
|
416
|
+
});
|
|
417
|
+
} catch {
|
|
418
|
+
}
|
|
419
|
+
}
|
package/src/renderers.ts
ADDED
|
@@ -0,0 +1,187 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* TUI renderers for pier's own session custom entries and reminder messages: raw JSON rows bury the
|
|
3
|
+
* one line that matters. Pure string builders plus an ANSI-aware truncator, so no pi-tui import is
|
|
4
|
+
* needed and older pi builds degrade to "no renderer".
|
|
5
|
+
*/
|
|
6
|
+
import { TODO_EDIT_CUSTOM_TYPE, type TodoEditPayload } from './todo-core.ts';
|
|
7
|
+
import { SUBS_CUSTOM_TYPE, type SubsRegistry } from './subagent-core.ts';
|
|
8
|
+
import { TERMINALS_CUSTOM_TYPE, TERM_REMINDER_CUSTOM_TYPE, type TerminalsRegistry } from './terminal-core.ts';
|
|
9
|
+
import { TODO_REMINDER_CUSTOM_TYPE } from './todo-reminder-core.ts';
|
|
10
|
+
|
|
11
|
+
// Width handling lives in ansi-text.ts so ask_user_question can reuse it without this import graph.
|
|
12
|
+
import { truncateStyled } from './ansi-text.ts';
|
|
13
|
+
|
|
14
|
+
/** Role manifest entry is written by index.ts; keep the literal here to avoid an import cycle. */
|
|
15
|
+
export const ROLE_MANIFEST_CUSTOM_TYPE = 'pi-herdr.role-manifest';
|
|
16
|
+
/** Soft-approval trace from the worker manifest gate in index.ts. */
|
|
17
|
+
export const APPROVAL_NEEDED_CUSTOM_TYPE = 'pi-herdr.approval-needed';
|
|
18
|
+
|
|
19
|
+
/** Structural subset of pi's Theme actually used here. */
|
|
20
|
+
export interface RenderTheme {
|
|
21
|
+
fg(color: string, text: string): string;
|
|
22
|
+
bold(text: string): string;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export interface RenderComponent {
|
|
26
|
+
render(width: number): string[];
|
|
27
|
+
invalidate(): void;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
type Renderer = (payload: unknown, options: { expanded: boolean }, theme: RenderTheme) => RenderComponent | undefined;
|
|
31
|
+
|
|
32
|
+
export interface RendererApi {
|
|
33
|
+
registerEntryRenderer?(customType: string, renderer: Renderer): void;
|
|
34
|
+
registerMessageRenderer?(customType: string, renderer: Renderer): void;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
function clip(text: string, max: number): string {
|
|
38
|
+
const flat = text.replace(/\s+/g, ' ').trim();
|
|
39
|
+
return flat.length <= max ? flat : `${flat.slice(0, max - 1)}…`;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
function card(lines: readonly string[]): RenderComponent {
|
|
43
|
+
return {
|
|
44
|
+
render: (width: number) => lines.map((line) => truncateStyled(line, width)),
|
|
45
|
+
invalidate() { /* No cached state. */ },
|
|
46
|
+
};
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
const EDIT_VERB: Record<string, string> = {
|
|
50
|
+
done: '✓ done',
|
|
51
|
+
drop: '■ dropped',
|
|
52
|
+
unblock: '○ unblocked',
|
|
53
|
+
rm: '✗ removed',
|
|
54
|
+
};
|
|
55
|
+
|
|
56
|
+
function formatEditCounts(edits: ReadonlyArray<{ op: string }>): string {
|
|
57
|
+
const counts = new Map<string, number>();
|
|
58
|
+
for (const edit of edits) counts.set(edit.op, (counts.get(edit.op) ?? 0) + 1);
|
|
59
|
+
return [...counts.entries()]
|
|
60
|
+
.map(([op, n]) => `${EDIT_VERB[op] ?? op}${n > 1 ? ` ×${n}` : ''}`)
|
|
61
|
+
.join(' · ');
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
function todoEditLines(data: unknown, theme: RenderTheme, expanded: boolean): string[] {
|
|
65
|
+
const payload = data as TodoEditPayload | undefined;
|
|
66
|
+
const edits = Array.isArray(payload?.edits) ? payload.edits : [];
|
|
67
|
+
if (edits.length === 0) return [theme.fg('dim', 'todo · no edits')];
|
|
68
|
+
const head = `${theme.fg('accent', theme.bold('todo'))} ${theme.fg('dim', `· ${edits.length} edit${edits.length > 1 ? 's' : ''}`)} ${formatEditCounts(edits)}`;
|
|
69
|
+
if (!expanded) return [head];
|
|
70
|
+
return [
|
|
71
|
+
head,
|
|
72
|
+
...edits.map((edit) => theme.fg('dim', ` ${EDIT_VERB[edit.op] ?? edit.op} — ${clip(String(edit.content ?? ''), 90)}`)),
|
|
73
|
+
];
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/** Registry card, shared by the subagent and terminal registries: a titled count line plus one row
|
|
77
|
+
* per entry when expanded; an empty or malformed registry renders one dim placeholder. */
|
|
78
|
+
function registryLines<T>(
|
|
79
|
+
items: readonly T[] | undefined,
|
|
80
|
+
theme: RenderTheme,
|
|
81
|
+
expanded: boolean,
|
|
82
|
+
card: { label: string; unit: string; badge?: string; row: (item: T) => string },
|
|
83
|
+
): string[] {
|
|
84
|
+
if (!items || items.length === 0) return [theme.fg('dim', `${card.label} · none`)];
|
|
85
|
+
const head = `${theme.fg('accent', theme.bold(card.label))} ${theme.fg('dim', `· ${items.length} ${card.unit}`)}${card.badge ? ` ${theme.fg('success', card.badge)}` : ''}`;
|
|
86
|
+
if (!expanded) return [head];
|
|
87
|
+
return [head, ...items.map((item) => theme.fg('dim', card.row(item)))];
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
function subsLines(data: unknown, theme: RenderTheme, expanded: boolean): string[] {
|
|
91
|
+
const subs = (data as SubsRegistry | undefined)?.subs;
|
|
92
|
+
const running = Array.isArray(subs) ? subs.filter((sub) => sub.status === 'running').length : 0;
|
|
93
|
+
return registryLines(Array.isArray(subs) ? subs : undefined, theme, expanded, {
|
|
94
|
+
label: 'subagents',
|
|
95
|
+
unit: 'tracked',
|
|
96
|
+
...(running ? { badge: `${running} running` } : {}),
|
|
97
|
+
row: (sub) => ` ${sub.status === 'running' ? '●' : '○'} ${sub.paneId} · ${sub.status} · ${clip(String(sub.description ?? ''), 70)}`,
|
|
98
|
+
});
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
function terminalsLines(data: unknown, theme: RenderTheme, expanded: boolean): string[] {
|
|
102
|
+
const terminals = (data as TerminalsRegistry | undefined)?.terminals;
|
|
103
|
+
return registryLines(Array.isArray(terminals) ? terminals : undefined, theme, expanded, {
|
|
104
|
+
label: 'terminals',
|
|
105
|
+
unit: 'open',
|
|
106
|
+
row: (t) => ` ${t.paneId} · ${clip(String(t.label ?? t.cwd ?? ''), 70)}`,
|
|
107
|
+
});
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
function roleManifestLines(data: unknown, theme: RenderTheme): string[] {
|
|
111
|
+
const rec = (data ?? {}) as {
|
|
112
|
+
role?: unknown;
|
|
113
|
+
manifestVersion?: unknown;
|
|
114
|
+
tools?: unknown;
|
|
115
|
+
permissions?: unknown;
|
|
116
|
+
origin?: unknown;
|
|
117
|
+
switchedBy?: unknown;
|
|
118
|
+
};
|
|
119
|
+
const role = typeof rec.role === 'string' ? rec.role : '?';
|
|
120
|
+
const version = typeof rec.manifestVersion === 'string' || typeof rec.manifestVersion === 'number' ? String(rec.manifestVersion) : '?';
|
|
121
|
+
const tools = Array.isArray(rec.tools) ? rec.tools.length : 0;
|
|
122
|
+
const gates = Object.values((rec.permissions ?? {}) as Record<string, unknown>)
|
|
123
|
+
.filter((v) => v === 'deny' || v === 'ask').length;
|
|
124
|
+
const tail = gates > 0 ? theme.fg('warning', ` · ${gates} gated`) : '';
|
|
125
|
+
// P0: switch provenance — origin 'switch' marks a mid-session swap (vs the session_start anchor).
|
|
126
|
+
const switched =
|
|
127
|
+
rec.origin === 'switch'
|
|
128
|
+
? theme.fg('accent', ` ⇄ ${typeof rec.switchedBy === 'string' && rec.switchedBy ? rec.switchedBy : '?'}`)
|
|
129
|
+
: '';
|
|
130
|
+
return [`${theme.fg('accent', theme.bold('role'))} ${role} ${theme.fg('dim', `v${version} · ${tools} tools`)}${tail}${switched}`];
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
function approvalLines(data: unknown, theme: RenderTheme): string[] {
|
|
134
|
+
const rec = (data ?? {}) as { role?: unknown; tool?: unknown };
|
|
135
|
+
const role = typeof rec.role === 'string' ? rec.role : '?';
|
|
136
|
+
const tool = typeof rec.tool === 'string' ? rec.tool : '?';
|
|
137
|
+
return [`${theme.fg('warning', '⚠ approval needed')} ${theme.fg('dim', `· ${tool} (${role})`)}`];
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
/** Reminder custom messages carry the full injected text; restyle without altering it. */
|
|
141
|
+
function reminderLines(message: unknown, theme: RenderTheme, label: string, expanded: boolean): string[] {
|
|
142
|
+
const content = typeof (message as { content?: unknown } | undefined)?.content === 'string'
|
|
143
|
+
? String((message as { content: string }).content)
|
|
144
|
+
: '';
|
|
145
|
+
const firstLine = content.split('\n').find((line) => line.trim().length > 0) ?? label;
|
|
146
|
+
const head = `${theme.fg('warning', `↻ ${label}`)}`;
|
|
147
|
+
if (!expanded) return [head, theme.fg('dim', ` ${clip(firstLine, 110)}`)];
|
|
148
|
+
return [head, ...content.split('\n').map((line) => theme.fg('dim', line))];
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
type LineBuilder = (data: unknown, theme: RenderTheme, expanded: boolean) => string[];
|
|
152
|
+
|
|
153
|
+
/** Register pier's transcript renderers and return the custom types actually registered, so the
|
|
154
|
+
* caller can log coverage and tests can assert the degrade path. Each registration is individually
|
|
155
|
+
* guarded: a pi build without the API must never break tool registration. */
|
|
156
|
+
export function installRenderers(pi: unknown): string[] {
|
|
157
|
+
const api = pi as RendererApi;
|
|
158
|
+
const registered: string[] = [];
|
|
159
|
+
const entry = (type: string, build: LineBuilder): void => {
|
|
160
|
+
if (typeof api.registerEntryRenderer !== 'function') return;
|
|
161
|
+
try {
|
|
162
|
+
api.registerEntryRenderer(type, (entryData, options, theme) =>
|
|
163
|
+
card(build((entryData as { data?: unknown } | undefined)?.data, theme, options.expanded)));
|
|
164
|
+
registered.push(type);
|
|
165
|
+
} catch {
|
|
166
|
+
/* Older pi or a conflicting renderer: keep the default rendering. */
|
|
167
|
+
}
|
|
168
|
+
};
|
|
169
|
+
const message = (type: string, build: LineBuilder): void => {
|
|
170
|
+
if (typeof api.registerMessageRenderer !== 'function') return;
|
|
171
|
+
try {
|
|
172
|
+
api.registerMessageRenderer(type, (msg, options, theme) => card(build(msg, theme, options.expanded)));
|
|
173
|
+
registered.push(type);
|
|
174
|
+
} catch {
|
|
175
|
+
/* A throwing renderer must not break message registration. */
|
|
176
|
+
}
|
|
177
|
+
};
|
|
178
|
+
|
|
179
|
+
entry(TODO_EDIT_CUSTOM_TYPE, todoEditLines);
|
|
180
|
+
entry(SUBS_CUSTOM_TYPE, subsLines);
|
|
181
|
+
entry(TERMINALS_CUSTOM_TYPE, terminalsLines);
|
|
182
|
+
entry(ROLE_MANIFEST_CUSTOM_TYPE, (data, theme) => roleManifestLines(data, theme));
|
|
183
|
+
entry(APPROVAL_NEEDED_CUSTOM_TYPE, (data, theme) => approvalLines(data, theme));
|
|
184
|
+
message(TODO_REMINDER_CUSTOM_TYPE, (msg, theme, expanded) => reminderLines(msg, theme, 'todo reminder', expanded));
|
|
185
|
+
message(TERM_REMINDER_CUSTOM_TYPE, (msg, theme, expanded) => reminderLines(msg, theme, 'terminal nudge', expanded));
|
|
186
|
+
return registered;
|
|
187
|
+
}
|