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.
Files changed (85) hide show
  1. package/README.md +26 -12
  2. package/package.json +8 -3
  3. package/schemas/efficiency-config.schema.json +77 -0
  4. package/schemas/role-manifest.schema.json +7 -1
  5. package/skills/pi-herdr-workflow/SKILL.md +11 -0
  6. package/src/ansi-text.ts +134 -0
  7. package/src/ask-dialog.ts +308 -0
  8. package/src/ask-user.ts +139 -88
  9. package/src/bootstrap.ts +13 -14
  10. package/src/compact-coordinator.ts +471 -0
  11. package/src/compact-economics-core.ts +328 -0
  12. package/src/config-catalog-core.ts +332 -0
  13. package/src/config-command.ts +388 -0
  14. package/src/dashboard-command.ts +108 -0
  15. package/src/efficiency-config-core.ts +464 -0
  16. package/src/efficiency-store.ts +299 -0
  17. package/src/focus-poller.ts +223 -0
  18. package/src/gc-core.ts +77 -18
  19. package/src/herdr-client.ts +284 -154
  20. package/src/history-store.ts +36 -23
  21. package/src/index-gates.ts +124 -0
  22. package/src/index-locks.ts +29 -16
  23. package/src/index-master.ts +29 -19
  24. package/src/index-notices.ts +35 -11
  25. package/src/index-pipe.ts +17 -7
  26. package/src/index-roles.ts +306 -0
  27. package/src/index-runtime.ts +2 -6
  28. package/src/index-worker.ts +7 -9
  29. package/src/index.ts +425 -388
  30. package/src/jev-client.ts +141 -0
  31. package/src/jev-core.ts +431 -0
  32. package/src/ledger.ts +13 -21
  33. package/src/lock-core.ts +98 -37
  34. package/src/manifest-compose.ts +35 -20
  35. package/src/observation-core.ts +200 -0
  36. package/src/pane-title.ts +19 -38
  37. package/src/pi-surface.ts +34 -22
  38. package/src/pier-options.ts +100 -0
  39. package/src/pipe-channel.ts +65 -52
  40. package/src/platform-paths.ts +19 -52
  41. package/src/{core → plugins}/grid-shape.ts +12 -12
  42. package/src/plugins/heat-plan.ts +158 -0
  43. package/src/plugins/observation.ts +475 -0
  44. package/src/plugins/subagent.ts +728 -0
  45. package/src/{core → plugins}/terminal.ts +237 -123
  46. package/src/{core → plugins}/todo.ts +75 -100
  47. package/src/progress-core.ts +16 -53
  48. package/src/reconcile-core.ts +24 -33
  49. package/src/reducer-core.ts +322 -0
  50. package/src/reducer-invoker.ts +419 -0
  51. package/src/renderers.ts +187 -0
  52. package/src/role-loader.ts +35 -29
  53. package/src/role-manifest.ts +23 -32
  54. package/src/role-state.ts +119 -0
  55. package/src/routing-telemetry.ts +136 -0
  56. package/src/runtime-policy.ts +25 -43
  57. package/src/session-tail.ts +102 -23
  58. package/src/settle-wake-core.ts +55 -22
  59. package/src/slim-frame.ts +29 -124
  60. package/src/stale-core.ts +17 -24
  61. package/src/storage-layout.ts +24 -30
  62. package/src/subagent-core.ts +256 -119
  63. package/src/subagent-gc.ts +131 -69
  64. package/src/subagent-poller.ts +401 -12
  65. package/src/subagent-session.ts +516 -0
  66. package/src/subagent-spawn.ts +651 -28
  67. package/src/swallow.ts +65 -0
  68. package/src/terminal-core.ts +160 -69
  69. package/src/todo-core.ts +62 -113
  70. package/src/todo-read-hook.ts +23 -39
  71. package/src/todo-reminder-core.ts +30 -48
  72. package/src/todo-window.ts +11 -12
  73. package/src/todos-service.ts +27 -35
  74. package/src/tool-error.ts +19 -0
  75. package/src/tool-gate.ts +38 -21
  76. package/src/vocab.ts +55 -25
  77. package/src/core/subagent.ts +0 -839
  78. package/src/git-adapter.ts +0 -104
  79. package/src/notice-buffer.ts +0 -23
  80. package/src/subagent-git-io.ts +0 -80
  81. package/src/subagent-launch.ts +0 -110
  82. package/src/subagent-poll-loop.ts +0 -285
  83. package/src/subagent-port.ts +0 -27
  84. package/src/subagent-scope.ts +0 -39
  85. package/src/subagent-session-io.ts +0 -147
@@ -0,0 +1,328 @@
1
+ /**
2
+ * D100 Online Context Compact economics & feasibility core: pure decision-making, request horizon
3
+ * estimation, cache debt tracking and native compaction feasibility preflights — no dependencies
4
+ * except pi's public cut-point helper.
5
+ */
6
+
7
+ import { findCutPoint, sessionEntryToContextMessages, type SessionEntry } from '@earendil-works/pi-coding-agent';
8
+
9
+ interface CompactionEconomics {
10
+ readonly remainingRequestScale: number;
11
+ readonly remainingRequestStddevK: number;
12
+ readonly windowReserveTokens: number;
13
+ readonly firstCompactionRequestScale: number;
14
+ readonly subsequentCompactionMargin: number;
15
+ }
16
+
17
+ export const DEFAULT_COMPACTION_ECONOMICS: CompactionEconomics = Object.freeze({
18
+ remainingRequestScale: 1.0,
19
+ remainingRequestStddevK: 0.0,
20
+ windowReserveTokens: 16_384,
21
+ firstCompactionRequestScale: 2.0,
22
+ subsequentCompactionMargin: 1.5,
23
+ });
24
+
25
+ /**
26
+ * Token-account cache ratio: a compaction request re-reads the whole context once (writeTokens), so
27
+ * breakeven = writeTokens / savingTokens ⇔ ratio 2. This is the `auto` fallback when no usable cache
28
+ * pricing exists, and a floor: ratio ≤ 1 would zero the incremental cost and compact even on the last
29
+ * boundary with nothing left to amortize.
30
+ */
31
+ export const TOKEN_ACCOUNT_CACHE_RATIO = 2.0;
32
+
33
+ /** Provider-family `auto` fallbacks (implicit-cache families without a write SKU). */
34
+ const PROVIDER_FAMILY_CACHE_RATIOS: Readonly<Record<string, number>> = Object.freeze({
35
+ gemini: 4,
36
+ grok: 10,
37
+ deepseek: 10,
38
+ });
39
+
40
+ type CompactionReason =
41
+ | 'economic'
42
+ | 'window_protection'
43
+ | 'deferred_economic'
44
+ | 'deferred_subsequent_margin'
45
+ | 'deferred_carried_debt'
46
+ | 'horizon_unavailable'
47
+ | 'cache_ratio_unavailable'
48
+ | 'native_not_compactable'
49
+ | 'failure_backoff'
50
+ | 'non_positive_saving';
51
+
52
+ interface RequestHorizonEstimate {
53
+ readonly requestsPerBoundaryMean: number;
54
+ readonly requestsPerBoundaryLowerBound: number;
55
+ readonly unboundedExpectedRemainingRequests: number;
56
+ readonly windowRequestUpperBound: number | null;
57
+ readonly expectedRemainingRequests: number;
58
+ }
59
+
60
+ export interface CompactionDecision {
61
+ readonly writeTokens: number;
62
+ readonly archiveTokens: number;
63
+ readonly memoTokens: number;
64
+ readonly contextTokens: number;
65
+ readonly expectedRemainingRequests: number | null;
66
+ readonly breakevenRequests: number | null;
67
+ readonly effectiveHorizonRequests: number | null;
68
+ readonly remainingBoundaries: number;
69
+ readonly cacheWriteReadRatio: number | null;
70
+ readonly incrementalCacheCostRatio: number | null;
71
+ readonly priorCompactionCount: number;
72
+ readonly carriedDebtTokens: number;
73
+ readonly cacheDebtRepaymentTokens: number;
74
+ readonly compact: boolean;
75
+ readonly reason: CompactionReason;
76
+ }
77
+
78
+ const MINIMUM_VARIANCE_SAMPLES = 3;
79
+ const SMALL_SAMPLE_SCALE = 0.5;
80
+ /** Retained boundary-request sample cap so the mean tracks recent pacing. */
81
+ export const MAX_BOUNDARY_SAMPLES = 16;
82
+
83
+ export function estimateRemainingRequests(input: {
84
+ readonly completedBoundaryRequestCounts: readonly number[];
85
+ readonly remainingBoundaries: number;
86
+ readonly scale: number;
87
+ readonly standardDeviationK: number;
88
+ readonly contextTokens: number;
89
+ readonly contextWindowTokens: number | null;
90
+ readonly averageContextTokenIncrement: number | null;
91
+ }): RequestHorizonEstimate {
92
+ const counts = input.completedBoundaryRequestCounts;
93
+ const mean = counts.reduce((total, count) => total + count, 0) / Math.max(1, counts.length);
94
+ let lowerBound = mean;
95
+ if (input.standardDeviationK !== 0) {
96
+ if (counts.length < MINIMUM_VARIANCE_SAMPLES) {
97
+ lowerBound *= SMALL_SAMPLE_SCALE;
98
+ } else {
99
+ const variance = counts.reduce((total, count) => total + (count - mean) ** 2, 0);
100
+ const deviation = Math.sqrt(variance / (counts.length - 1));
101
+ lowerBound = Math.max(0, mean - input.standardDeviationK * deviation);
102
+ }
103
+ }
104
+
105
+ const unboundedExpectedRemainingRequests =
106
+ 1 + Math.floor(lowerBound * Math.max(0, input.remainingBoundaries) * input.scale);
107
+ const windowRequestUpperBound =
108
+ input.contextWindowTokens === null ||
109
+ input.averageContextTokenIncrement === null ||
110
+ input.averageContextTokenIncrement <= 0
111
+ ? null
112
+ : Math.max(
113
+ 0,
114
+ Math.floor((input.contextWindowTokens - input.contextTokens) / input.averageContextTokenIncrement),
115
+ );
116
+
117
+ return {
118
+ requestsPerBoundaryMean: mean,
119
+ requestsPerBoundaryLowerBound: lowerBound,
120
+ unboundedExpectedRemainingRequests,
121
+ windowRequestUpperBound,
122
+ expectedRemainingRequests:
123
+ windowRequestUpperBound === null
124
+ ? unboundedExpectedRemainingRequests
125
+ : Math.min(unboundedExpectedRemainingRequests, windowRequestUpperBound),
126
+ };
127
+ }
128
+
129
+ export function decideCompaction(input: {
130
+ readonly writeTokens: number;
131
+ readonly archiveTokens: number;
132
+ readonly memoTokens: number;
133
+ readonly contextTokens: number;
134
+ readonly completedBoundaryRequestCounts: readonly number[] | null;
135
+ readonly remainingBoundaries: number;
136
+ readonly averageContextTokenIncrement: number | null;
137
+ readonly contextWindowTokens: number | null;
138
+ readonly priorCompactionCount: number;
139
+ readonly carriedDebtTokens: number;
140
+ readonly cacheDebtRepaymentTokens: number;
141
+ readonly cacheWriteReadRatio: number | null;
142
+ readonly economics?: CompactionEconomics;
143
+ }): CompactionDecision {
144
+ const economics = input.economics ?? DEFAULT_COMPACTION_ECONOMICS;
145
+ const horizon = input.completedBoundaryRequestCounts === null ? null : estimateRemainingRequests({
146
+ completedBoundaryRequestCounts: input.completedBoundaryRequestCounts,
147
+ remainingBoundaries: input.remainingBoundaries,
148
+ scale: economics.remainingRequestScale,
149
+ standardDeviationK: economics.remainingRequestStddevK,
150
+ contextTokens: input.contextTokens,
151
+ contextWindowTokens: input.contextWindowTokens,
152
+ averageContextTokenIncrement: input.averageContextTokenIncrement,
153
+ });
154
+
155
+ const savingTokens = input.archiveTokens - input.memoTokens;
156
+ const compressible = savingTokens > 0;
157
+ const incrementalCacheCostRatio =
158
+ input.cacheWriteReadRatio === null ? null : Math.max(0, input.cacheWriteReadRatio - 1);
159
+ const incrementalWriteCost = incrementalCacheCostRatio === null
160
+ ? null
161
+ : input.writeTokens * incrementalCacheCostRatio;
162
+
163
+ const breakevenRequests = compressible && incrementalWriteCost !== null
164
+ ? incrementalWriteCost / savingTokens
165
+ : null;
166
+ const combinedBreakevenRequests = compressible && incrementalWriteCost !== null
167
+ ? (input.carriedDebtTokens + incrementalWriteCost) / savingTokens
168
+ : null;
169
+
170
+ const firstCompaction = input.priorCompactionCount === 0;
171
+ const horizonRequests = horizon?.expectedRemainingRequests ?? null;
172
+ // The first-compaction relaxation absorbs cold-start sample noise, but only when work remains: with
173
+ // zero remaining boundaries the horizon is 1, and scaling it to 2 would burn a full-context summary.
174
+ const relaxFirstCompaction = firstCompaction && input.remainingBoundaries > 0;
175
+ const effectiveHorizonRequests = horizon === null
176
+ ? null
177
+ : relaxFirstCompaction
178
+ ? Math.min(
179
+ horizon.expectedRemainingRequests * economics.firstCompactionRequestScale,
180
+ horizon.windowRequestUpperBound ?? Number.POSITIVE_INFINITY,
181
+ )
182
+ : horizon.expectedRemainingRequests;
183
+
184
+ const windowProtection =
185
+ input.contextWindowTokens !== null &&
186
+ input.contextTokens >= input.contextWindowTokens - economics.windowReserveTokens;
187
+ const baseEconomic =
188
+ horizonRequests !== null &&
189
+ horizonRequests > 0 &&
190
+ breakevenRequests !== null &&
191
+ breakevenRequests <= horizonRequests;
192
+ const firstEconomic =
193
+ firstCompaction &&
194
+ effectiveHorizonRequests !== null &&
195
+ effectiveHorizonRequests > 0 &&
196
+ breakevenRequests !== null &&
197
+ breakevenRequests <= effectiveHorizonRequests;
198
+ const subsequentMarginOpen =
199
+ !firstCompaction &&
200
+ horizonRequests !== null &&
201
+ breakevenRequests !== null &&
202
+ breakevenRequests * economics.subsequentCompactionMargin <= horizonRequests;
203
+ const carriedDebtGateOpen =
204
+ !firstCompaction &&
205
+ horizonRequests !== null &&
206
+ combinedBreakevenRequests !== null &&
207
+ combinedBreakevenRequests <= horizonRequests;
208
+
209
+ const economic = firstCompaction ? firstEconomic : baseEconomic && subsequentMarginOpen && carriedDebtGateOpen;
210
+ const compact = compressible && (windowProtection || economic);
211
+
212
+ // Ordered guards: first hit wins, so the tail entry is the fallback.
213
+ const reason = ([
214
+ ['non_positive_saving', !compressible],
215
+ ['window_protection', windowProtection],
216
+ ['economic', economic],
217
+ ['horizon_unavailable', horizon === null],
218
+ ['cache_ratio_unavailable', breakevenRequests === null],
219
+ ['deferred_subsequent_margin', !firstCompaction && baseEconomic && !subsequentMarginOpen],
220
+ ['deferred_carried_debt', !firstCompaction && baseEconomic && !carriedDebtGateOpen],
221
+ ['deferred_economic', true],
222
+ ] as Array<[CompactionReason, boolean]>).find(([, hit]) => hit)![0];
223
+
224
+ return {
225
+ writeTokens: input.writeTokens,
226
+ archiveTokens: input.archiveTokens,
227
+ memoTokens: input.memoTokens,
228
+ contextTokens: input.contextTokens,
229
+ expectedRemainingRequests: horizonRequests,
230
+ breakevenRequests,
231
+ effectiveHorizonRequests,
232
+ remainingBoundaries: input.remainingBoundaries,
233
+ cacheWriteReadRatio: input.cacheWriteReadRatio,
234
+ incrementalCacheCostRatio,
235
+ priorCompactionCount: input.priorCompactionCount,
236
+ carriedDebtTokens: input.carriedDebtTokens,
237
+ cacheDebtRepaymentTokens: input.cacheDebtRepaymentTokens,
238
+ compact,
239
+ reason,
240
+ };
241
+ }
242
+
243
+ /**
244
+ * Resolve the cache write/read cost ratio for compaction decisions, in order: explicit numeric config
245
+ * (as-is, e.g. 12.5 for Anthropic-style pricing), model cost metadata (write/read, or input/cacheRead
246
+ * when cache writes are billed at the input price), provider-family fallback (gemini ≈ 4, grok/deepseek
247
+ * ≈ 10), then TOKEN_ACCOUNT_CACHE_RATIO. Returns null only for an explicitly invalid numeric config:
248
+ * `auto` must never resolve to null, since a zero-price table means "no local cacheWrite SKU", not
249
+ * "no cache", and null would disable OCC for every model in actual use.
250
+ */
251
+ export function resolveCacheRatioFromCost(
252
+ ratioConfig: number | 'auto',
253
+ cost?: { input?: number; cacheRead?: number; cacheWrite?: number } | null,
254
+ identity?: { provider?: string; modelId?: string } | null,
255
+ ): number | null {
256
+ if (typeof ratioConfig === 'number') {
257
+ return Number.isFinite(ratioConfig) && ratioConfig >= 0 ? ratioConfig : null;
258
+ }
259
+
260
+ if (cost) {
261
+ const read = cost.cacheRead ?? 0;
262
+ const write = cost.cacheWrite ?? 0;
263
+ const input = cost.input ?? 0;
264
+ if (read > 0 && write > 0) return write / read;
265
+ if (read > 0 && write === 0 && input > 0) return input / read;
266
+ }
267
+
268
+ const familyKey = `${identity?.provider ?? ''}/${identity?.modelId ?? ''}`.toLowerCase();
269
+ for (const [family, ratio] of Object.entries(PROVIDER_FAMILY_CACHE_RATIOS)) {
270
+ if (familyKey.includes(family)) return ratio;
271
+ }
272
+ return TOKEN_ACCOUNT_CACHE_RATIO;
273
+ }
274
+
275
+ function compactionMessageCount(entries: readonly SessionEntry[], startIndex: number, endIndex: number): number {
276
+ let count = 0;
277
+ for (let index = startIndex; index < endIndex; index++) {
278
+ const entry = entries[index];
279
+ if (entry && entry.type !== 'compaction' && sessionEntryToContextMessages(entry).length > 0) count++;
280
+ }
281
+ return count;
282
+ }
283
+
284
+ /** Appends the abort marker pi's cut-point search expects after our intentional abort. */
285
+ function branchAfterAbort(entries: readonly SessionEntry[]): SessionEntry[] {
286
+ return [
287
+ ...entries,
288
+ {
289
+ type: 'message',
290
+ id: 'pi-herdr-online-context-compact-abort-marker',
291
+ parentId: entries.at(-1)?.id ?? null,
292
+ timestamp: new Date(0).toISOString(),
293
+ message: {
294
+ role: 'assistant',
295
+ content: [],
296
+ api: 'pi-herdr',
297
+ provider: 'pi-herdr',
298
+ model: 'aborted',
299
+ usage: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, totalTokens: 0, cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 } },
300
+ stopReason: 'aborted',
301
+ timestamp: 0,
302
+ },
303
+ } as SessionEntry,
304
+ ];
305
+ }
306
+
307
+ /** Would pi's own compaction find history to summarize on this branch? OCC must not abort otherwise. */
308
+ export function nativeCompactionFeasible(entries: readonly SessionEntry[], keepRecentTokens: number): boolean {
309
+ if (!Array.isArray(entries) || entries.length === 0) return false;
310
+ const path = branchAfterAbort(entries);
311
+ let startIndex = 0;
312
+ for (let index = path.length - 1; index >= 0; index--) {
313
+ const entry = path[index];
314
+ if (entry?.type !== 'compaction') continue;
315
+ const keptIndex = path.findIndex((item) => item.id === (entry as { firstKeptEntryId?: string }).firstKeptEntryId);
316
+ startIndex = keptIndex >= 0 ? keptIndex : index + 1;
317
+ break;
318
+ }
319
+
320
+ const cut = findCutPoint(path, startIndex, path.length, keepRecentTokens);
321
+ const historyEnd = cut.isSplitTurn ? cut.turnStartIndex : cut.firstKeptEntryIndex;
322
+ const historyMessages = historyEnd > startIndex ? compactionMessageCount(path, startIndex, historyEnd) : 0;
323
+ const prefixMessages =
324
+ cut.isSplitTurn && cut.turnStartIndex >= 0
325
+ ? compactionMessageCount(path, cut.turnStartIndex, cut.firstKeptEntryIndex)
326
+ : 0;
327
+ return historyMessages > 0 || prefixMessages > 0;
328
+ }
@@ -0,0 +1,332 @@
1
+ /**
2
+ * D104 config catalog core (pure).
3
+ *
4
+ * Single source of truth for the four pier configuration planes: knob descriptors, effective-value
5
+ * provenance (env > workspace > user > default) and rendering for `/pier-config show|check|doc`. The
6
+ * `env` plane derives from the `PIER_OPTIONS` registry, so it cannot disagree with the runtime readers;
7
+ * no I/O here (config-command.ts does the reading) and test/config-catalog.test.ts guards the mirror.
8
+ */
9
+
10
+ import { PIER_OPTIONS } from './pier-options.ts';
11
+
12
+ export type ConfigPlaneId = 'efficiency' | 'roles' | 'pi' | 'env';
13
+
14
+ export type ConfigKind = 'boolean' | 'number' | 'string' | 'enum';
15
+
16
+ export interface ConfigKnob {
17
+ readonly plane: ConfigPlaneId;
18
+ /** Dotted path inside the plane's file, or the env var name for the `env` plane. */
19
+ readonly key: string;
20
+ readonly kind: ConfigKind;
21
+ readonly defaultValue?: string | number | boolean;
22
+ /** Highest-precedence env override for a file-plane knob. */
23
+ readonly envVar?: string;
24
+ /** Lower bound for numeric knobs (mirrors the runtime validator). */
25
+ readonly min?: number;
26
+ /** Historical spellings still accepted for an env knob (B10: canonical `PIER_*` first). */
27
+ readonly aliases?: readonly string[];
28
+ readonly impact: string;
29
+ readonly docRef?: string;
30
+ /** True when pier only reads the value (owned by pi / herdr). */
31
+ readonly readOnly?: boolean;
32
+ }
33
+
34
+ export interface ConfigPlane {
35
+ readonly id: ConfigPlaneId;
36
+ readonly title: string;
37
+ readonly owner: 'pier' | 'pi';
38
+ /** Human-facing path templates, in precedence order. */
39
+ readonly files: readonly string[];
40
+ readonly editHint: string;
41
+ }
42
+
43
+ export const CONFIG_PLANES: readonly ConfigPlane[] = Object.freeze([
44
+ {
45
+ id: 'efficiency',
46
+ title: 'Efficiency mechanisms (D100-D103)',
47
+ owner: 'pier',
48
+ files: ['<workspace>/.pi-herdr/config.json', '~/.pi/agent/herdr-pi/config.json', 'PI_HERDR_* env'],
49
+ editHint: 'Edit the workspace or user JSON (env wins); see docs/efficiency-trial.md',
50
+ },
51
+ {
52
+ id: 'roles',
53
+ title: 'Role profiles (D82/D11)',
54
+ owner: 'pier',
55
+ files: ['<workspace>/.pi-herdr/roles/<name>.json', '~/.pi/agent/herdr-pi/roles/<name>.json', 'builtin src/roles/'],
56
+ editHint: 'Add or edit a role JSON; builtin names (master/worker-default) cannot be overridden',
57
+ },
58
+ {
59
+ id: 'pi',
60
+ title: "Pi's own settings (read-only here)",
61
+ owner: 'pi',
62
+ files: ['~/.pi/agent/settings.json', '<trusted-project>/.pi/settings.json'],
63
+ editHint: 'Use pi\'s /settings for everything except the OCC-relevant compaction.* keys',
64
+ },
65
+ {
66
+ id: 'env',
67
+ title: 'Runtime policy env (PIER_* / PI_HERDR_*)',
68
+ owner: 'pier',
69
+ files: ['process environment (per-process, no file)'],
70
+ editHint: 'Export the variable before starting pi; it applies to new processes only',
71
+ },
72
+ ] as const);
73
+
74
+ /** Efficiency knobs: mirrors schemas/efficiency-config.schema.json (guarded by the catalog test). */
75
+ const EFFICIENCY_KNOBS: readonly ConfigKnob[] = [
76
+ { plane: 'efficiency', key: 'onlineContextCompact.enabled', kind: 'boolean', defaultValue: false, envVar: 'PI_HERDR_COMPACT_ENABLE', impact: 'Todo-driven online compaction (OCC) master switch', docRef: 'docs/efficiency-trial.md' },
77
+ { plane: 'efficiency', key: 'onlineContextCompact.logEnabled', kind: 'boolean', defaultValue: false, envVar: 'PI_HERDR_COMPACT_LOG', impact: 'Write compact.jsonl decisions', docRef: 'docs/efficiency-trial.md' },
78
+ { plane: 'efficiency', key: 'onlineContextCompact.cacheWriteReadRatio', kind: 'string', defaultValue: 'auto', envVar: 'PI_HERDR_CACHE_RATIO', impact: 'KV cache write/read cost ratio; auto = model cost, then input/cacheRead, then provider family (gemini 4 / grok+deepseek 10), then token-account 2.0 — never disables OCC', docRef: 'docs/efficiency-trial.md' },
79
+ { plane: 'efficiency', key: 'onlineContextCompact.firstCompactionRequestScale', kind: 'number', defaultValue: 2.0, min: 1.0, impact: 'First-compaction horizon relaxation', docRef: 'docs/efficiency-trial.md' },
80
+ { plane: 'efficiency', key: 'onlineContextCompact.subsequentCompactionMargin', kind: 'number', defaultValue: 1.5, min: 1.0, impact: 'Safety margin required for later compactions', docRef: 'docs/efficiency-trial.md' },
81
+ { plane: 'efficiency', key: 'onlineContextCompact.keepRecentTokens', kind: 'number', defaultValue: 20000, min: 1000, impact: 'Native compaction tail window; inherited from pi unless set here', docRef: 'docs/configuration.md' },
82
+ { plane: 'efficiency', key: 'observationPack.enabled', kind: 'boolean', defaultValue: false, envVar: 'PI_HERDR_OBS_PACK_ENABLE', impact: 'Project large tool outputs as placeholders (observationPack)', docRef: 'docs/efficiency-trial.md' },
83
+ { plane: 'efficiency', key: 'observationPack.logEnabled', kind: 'boolean', defaultValue: false, envVar: 'PI_HERDR_OBS_PACK_LOG', impact: 'Write observation.jsonl packed/recall records', docRef: 'docs/efficiency-trial.md' },
84
+ { plane: 'efficiency', key: 'observationPack.thresholdBytes', kind: 'number', defaultValue: 10240, min: 1024, impact: 'Minimum output size before packing applies', docRef: 'docs/efficiency-trial.md' },
85
+ { plane: 'efficiency', key: 'observationPack.fullSends', kind: 'number', defaultValue: 2, min: 1, impact: 'Provider requests that keep the full text before packing', docRef: 'docs/efficiency-trial.md' },
86
+ { plane: 'efficiency', key: 'observationPack.recallChunkBytes', kind: 'number', defaultValue: 16384, min: 1024, impact: 'obs_recall page size', docRef: 'docs/efficiency-trial.md' },
87
+ { plane: 'efficiency', key: 'observationPack.excerptBytes', kind: 'number', defaultValue: 1024, min: 128, impact: 'Whole-line head/tail excerpt kept in the placeholder', docRef: 'docs/efficiency-trial.md' },
88
+ { plane: 'efficiency', key: 'evidencePreservingReducer.enabled', kind: 'boolean', defaultValue: false, envVar: 'PI_HERDR_REDUCER_ENABLE', impact: 'Reduce long bash diagnostic logs to a verified receipt (EPR)', docRef: 'docs/efficiency-trial.md' },
89
+ { plane: 'efficiency', key: 'evidencePreservingReducer.logEnabled', kind: 'boolean', defaultValue: false, envVar: 'PI_HERDR_REDUCER_LOG', impact: 'Write reducer.jsonl attempts and fallback reasons', docRef: 'docs/efficiency-trial.md' },
90
+ { plane: 'efficiency', key: 'evidencePreservingReducer.model', kind: 'string', envVar: 'PI_HERDR_REDUCER_MODEL', impact: 'Cheap model used for reduction (provider/model); defaults to the session model', docRef: 'docs/efficiency-trial.md' },
91
+ { plane: 'efficiency', key: 'evidencePreservingReducer.minBytes', kind: 'number', defaultValue: 4096, min: 512, impact: 'Minimum log size before reduction is attempted', docRef: 'docs/efficiency-trial.md' },
92
+ { plane: 'efficiency', key: 'evidencePreservingReducer.maxChars', kind: 'number', defaultValue: 600000, min: 1000, impact: 'Skip reduction above this log size', docRef: 'docs/efficiency-trial.md' },
93
+ { plane: 'efficiency', key: 'evidencePreservingReducer.maxOutputTokens', kind: 'number', defaultValue: 2048, min: 128, impact: 'Reduction output token budget', docRef: 'docs/efficiency-trial.md' },
94
+ { plane: 'efficiency', key: 'evidencePreservingReducer.timeoutMs', kind: 'number', defaultValue: 5000, min: 500, impact: 'Synchronous reduction budget; timeout falls back to the full log', docRef: 'docs/efficiency-trial.md' },
95
+ { plane: 'efficiency', key: 'evidencePreservingReducer.localOnly', kind: 'boolean', defaultValue: false, impact: 'Archive the raw log but never call a model', docRef: 'docs/efficiency-trial.md' },
96
+ { plane: 'efficiency', key: 'jev.enabled', kind: 'boolean', defaultValue: false, envVar: 'PIER_JEV_ENABLE', impact: 'Jev decision layer (System One classification) master switch; every call site fails open', docRef: 'docs/rfc-jev-integration.md' },
97
+ { plane: 'efficiency', key: 'jev.logEnabled', kind: 'boolean', defaultValue: false, envVar: 'PIER_JEV_LOG', impact: 'Write jev.jsonl question outcomes (no request/response bodies)', docRef: 'docs/rfc-jev-integration.md' },
98
+ { plane: 'efficiency', key: 'jev.baseUrl', kind: 'string', envVar: 'PIER_JEV_BASE_URL', impact: 'API root override (relay/gateway); default https://api.typesafe.ai', docRef: 'docs/rfc-jev-integration.md' },
99
+ { plane: 'efficiency', key: 'jev.model', kind: 'string', defaultValue: 'jev-1.13.0', envVar: 'PIER_JEV_MODEL', impact: 'Pinned versioned model id; aliases drift silently and skew tuned thresholds', docRef: 'docs/rfc-jev-integration.md' },
100
+ { plane: 'efficiency', key: 'jev.timeoutMs', kind: 'number', defaultValue: 2000, min: 500, envVar: 'PIER_JEV_TIMEOUT_MS', impact: 'Total per-call budget via AbortController hard kill', docRef: 'docs/rfc-jev-integration.md' },
101
+ { plane: 'efficiency', key: 'jev.minConfidence', kind: 'number', defaultValue: 0.6, min: 0, envVar: 'PIER_JEV_MIN_CONFIDENCE', impact: 'Minimum Choice/Score confidence to adopt an answer; below counts as unanswered', docRef: 'docs/rfc-jev-integration.md' },
102
+ { plane: 'efficiency', key: 'jev.apiKey', kind: 'string', envVar: 'PIER_JEV_API_KEY', impact: 'API key; PIER_JEV_API_KEY env > config value > TYPESAFE_API_KEY env', docRef: 'docs/rfc-jev-integration.md' },
103
+ ];
104
+
105
+ /** Pi-owned keys pier only reads. */
106
+ const PI_KNOBS: readonly ConfigKnob[] = [
107
+ { plane: 'pi', key: 'compaction.enabled', kind: 'boolean', impact: 'When false, OCC is disabled too (unless PI_HERDR_COMPACT_ENABLE=1)', docRef: 'docs/efficiency-trial.md', readOnly: true },
108
+ { plane: 'pi', key: 'compaction.keepRecentTokens', kind: 'number', impact: 'Inherited as the OCC retention window when not set in the efficiency config', docRef: 'docs/efficiency-trial.md', readOnly: true },
109
+ ];
110
+
111
+ /** Runtime policy / behaviour env knobs, derived from the `PIER_OPTIONS` registry. */
112
+ const ENV_KNOBS: readonly ConfigKnob[] = PIER_OPTIONS.map((option) => ({
113
+ plane: 'env' as const,
114
+ key: option.name,
115
+ kind: option.min === undefined ? ('string' as const) : ('number' as const),
116
+ defaultValue: option.min === undefined
117
+ ? (option.fallback === '' ? undefined : option.fallback)
118
+ : Number(option.fallback),
119
+ aliases: option.legacy ? [option.legacy] : undefined,
120
+ min: option.min,
121
+ impact: option.description,
122
+ }));
123
+
124
+ /** Every knob the catalog can resolve (roles is a per-file plane: its schema is the reference). */
125
+ export const CONFIG_KNOBS: readonly ConfigKnob[] = Object.freeze([...EFFICIENCY_KNOBS, ...PI_KNOBS, ...ENV_KNOBS]);
126
+
127
+ export function readDotted(obj: unknown, dotted: string): unknown {
128
+ let cursor: unknown = obj;
129
+ for (const part of dotted.split('.')) {
130
+ if (!cursor || typeof cursor !== 'object' || Array.isArray(cursor)) return undefined;
131
+ cursor = (cursor as Record<string, unknown>)[part];
132
+ }
133
+ return cursor;
134
+ }
135
+
136
+ /** Secret-shaped KEY names (not values). Deliberately narrower than a bare `token` match so that
137
+ * legitimate keys like `compaction.keepRecentTokens` are still shown verbatim. */
138
+ const SECRET_LIKE = /(api[_-]?key|apikey|authorization|bearer|access[_-]?token|client[_-]?secret|password|credential)/i;
139
+
140
+ export function formatValue(value: unknown): string {
141
+ if (value === undefined) return '(unset)';
142
+ if (typeof value === 'string') return value === '' ? '""' : value;
143
+ if (typeof value === 'number' || typeof value === 'boolean') return String(value);
144
+ if (value === null) return 'null';
145
+ return JSON.stringify(value);
146
+ }
147
+
148
+ export function redactValue(key: string, value: unknown): string {
149
+ if (SECRET_LIKE.test(key) && value !== undefined && value !== '') return '***';
150
+ return formatValue(value);
151
+ }
152
+
153
+ export type ConfigSource = 'env' | 'workspace' | 'user' | 'default' | 'pi';
154
+
155
+ export interface ResolvedKnob {
156
+ readonly knob: ConfigKnob;
157
+ readonly value: string;
158
+ readonly source: ConfigSource;
159
+ readonly via?: string;
160
+ /** Set when a workspace layer exists but was ignored (untrusted project). */
161
+ readonly note?: string;
162
+ }
163
+
164
+ export interface RawConfigLayers {
165
+ readonly env?: Record<string, string | undefined>;
166
+ /** Parsed workspace efficiency config (untrusted layers are reported, never honored). */
167
+ readonly workspace?: unknown;
168
+ readonly user?: unknown;
169
+ readonly workspaceTrusted?: boolean;
170
+ /** Result of loadPiNativeCompactionSettings (pi-owned values pier reads). */
171
+ readonly piSettings?: { enabled?: boolean; keepRecentTokens?: number };
172
+ }
173
+
174
+ function resolveFileKnob(knob: ConfigKnob, layers: RawConfigLayers): ResolvedKnob {
175
+ const envRaw = knob.envVar ? (layers.env ?? {})[knob.envVar] : undefined;
176
+ if (envRaw !== undefined && envRaw !== '') return { knob, value: redactValue(knob.key, envRaw), source: 'env' };
177
+
178
+ const workspaceValue = readDotted(layers.workspace, knob.key);
179
+ const userValue = readDotted(layers.user, knob.key);
180
+ if (workspaceValue !== undefined) {
181
+ if (layers.workspaceTrusted) return { knob, value: redactValue(knob.key, workspaceValue), source: 'workspace' };
182
+ return userValue === undefined
183
+ ? { knob, value: redactValue(knob.key, knob.defaultValue), source: 'default', note: 'workspace value ignored (untrusted project)' }
184
+ : { knob, value: redactValue(knob.key, userValue), source: 'user', note: 'workspace layer ignored (untrusted project)' };
185
+ }
186
+ if (userValue !== undefined) return { knob, value: redactValue(knob.key, userValue), source: 'user' };
187
+ return { knob, value: redactValue(knob.key, knob.defaultValue), source: 'default' };
188
+ }
189
+
190
+ export function resolveConfigKnobs(layers: RawConfigLayers): ResolvedKnob[] {
191
+ const out = EFFICIENCY_KNOBS.map((knob) => resolveFileKnob(knob, layers));
192
+
193
+ // Effective-value rule from loadEfficiencyConfigFromDisk: pi's compaction.enabled=false
194
+ // disables OCC unless PI_HERDR_COMPACT_ENABLE forces it on.
195
+ const occ = out.find((e) => e.knob.key === 'onlineContextCompact.enabled');
196
+ const envForced = (layers.env?.PI_HERDR_COMPACT_ENABLE ?? '') !== '';
197
+ if (occ && occ.value === 'true' && layers.piSettings?.enabled === false && !envForced) {
198
+ out[out.indexOf(occ)] = { ...occ, value: 'false', note: 'pi compaction.enabled=false disables OCC (set PI_HERDR_COMPACT_ENABLE=1 to force)' };
199
+ }
200
+
201
+ for (const knob of PI_KNOBS) {
202
+ const raw = knob.key === 'compaction.enabled' ? layers.piSettings?.enabled : layers.piSettings?.keepRecentTokens;
203
+ out.push(raw === undefined
204
+ ? { knob, value: formatValue(undefined), source: 'pi', note: 'not set in pi settings' }
205
+ : { knob, value: redactValue(knob.key, raw), source: 'pi' });
206
+ }
207
+ return out;
208
+ }
209
+
210
+ /** Resolves the env plane: only catalog keys are ever read. */
211
+ export function resolveEnvKnobs(env: Record<string, string | undefined> = {}): ResolvedKnob[] {
212
+ return ENV_KNOBS.map((knob) => {
213
+ // Same precedence as pierOption(): canonical first, then the historical spelling, empty = unset.
214
+ for (const name of [knob.key, ...(knob.aliases ?? [])]) {
215
+ const raw = env[name];
216
+ if (raw === undefined || raw === '') continue;
217
+ return { knob, value: redactValue(knob.key, raw), source: 'env' as const, ...(name === knob.key ? {} : { via: name }) };
218
+ }
219
+ return { knob, value: redactValue(knob.key, knob.defaultValue), source: 'default' as const };
220
+ });
221
+ }
222
+
223
+ export interface CheckReport {
224
+ readonly plane: ConfigPlaneId;
225
+ readonly ok: boolean;
226
+ readonly issues: readonly string[];
227
+ }
228
+
229
+ export function checkEnvKnobs(env: Record<string, string | undefined> = {}): string[] {
230
+ const issues: string[] = [];
231
+ for (const knob of ENV_KNOBS) {
232
+ if (knob.kind !== 'number') continue;
233
+ for (const name of [knob.key, ...(knob.aliases ?? [])]) {
234
+ const raw = env[name];
235
+ if (raw === undefined || raw === '') continue;
236
+ const parsed = Number.parseInt(raw, 10);
237
+ const min = knob.min ?? 0;
238
+ if (!Number.isFinite(parsed) || parsed < min) {
239
+ issues.push(`${name}="${raw}" is not a valid integer >= ${min} (runtime falls back to ${formatValue(knob.defaultValue)})`);
240
+ }
241
+ }
242
+ }
243
+ return issues;
244
+ }
245
+
246
+ function sourceLabel(entry: ResolvedKnob): string {
247
+ const base = entry.via ? `${entry.source} via ${entry.via}` : entry.source;
248
+ return entry.note ? `${base} (${entry.note})` : base;
249
+ }
250
+
251
+ export function summarizePlane(entries: readonly ResolvedKnob[]): string {
252
+ const changed = entries.filter((e) => e.source !== 'default');
253
+ if (changed.length === 0) return 'all defaults';
254
+ const bySource = new Map<ConfigSource, number>();
255
+ for (const e of changed) bySource.set(e.source, (bySource.get(e.source) ?? 0) + 1);
256
+ return `${changed.length} set (${[...bySource].map(([s, n]) => `${s} ${n}`).join(', ')})`;
257
+ }
258
+
259
+ export function renderIndex(lines: {
260
+ efficiency: readonly ResolvedKnob[];
261
+ pi: readonly ResolvedKnob[];
262
+ env: readonly ResolvedKnob[];
263
+ roleSummary: string;
264
+ }): string[] {
265
+ const mechanism = (prefix: string): string =>
266
+ lines.efficiency.some((e) => e.knob.key === `${prefix}.enabled` && e.value === 'true') ? 'on' : 'off';
267
+ return [
268
+ 'pier config — 4 planes, env > workspace > user > default',
269
+ ` efficiency ${summarizePlane(lines.efficiency)} — OCC ${mechanism('onlineContextCompact')} / OBS ${mechanism('observationPack')} / EPR ${mechanism('evidencePreservingReducer')}`,
270
+ ` roles ${lines.roleSummary}`,
271
+ ` pi ${summarizePlane(lines.pi)} — OCC reads compaction.* only`,
272
+ ` env ${summarizePlane(lines.env)}`,
273
+ ' show: /pier-config show <efficiency|roles|pi|env|all> check: /pier-config check report: /pier-config doc',
274
+ ];
275
+ }
276
+
277
+ export function renderPlane(entries: readonly ResolvedKnob[]): string[] {
278
+ const out: string[] = [];
279
+ for (const entry of entries) {
280
+ const { knob } = entry;
281
+ const from = knob.envVar && entry.source === 'env' ? ` (from ${knob.envVar})` : '';
282
+ const flags = [knob.readOnly ? 'read-only' : '', knob.docRef ?? ''].filter(Boolean).join('; ');
283
+ out.push(` ${knob.key}${from} = ${entry.value} [${sourceLabel(entry)}]${flags ? ` (${flags})` : ''}`);
284
+ out.push(` ${knob.impact}`);
285
+ }
286
+ return out;
287
+ }
288
+
289
+ export function renderCheck(reports: readonly CheckReport[]): string[] {
290
+ const out: string[] = [];
291
+ for (const report of reports) {
292
+ out.push(` ${report.ok ? 'ok ' : 'FAIL'} ${report.plane}`);
293
+ for (const issue of report.issues) out.push(` - ${issue}`);
294
+ }
295
+ return out;
296
+ }
297
+
298
+ export interface ReportMeta {
299
+ readonly generatedAt: string;
300
+ readonly cwd: string;
301
+ readonly workspaceTrusted: boolean;
302
+ readonly piVersion?: string;
303
+ }
304
+
305
+ export function renderReport(entries: readonly ResolvedKnob[], meta: ReportMeta): string {
306
+ const lines = [
307
+ '# pier config report',
308
+ '',
309
+ `- generated: ${meta.generatedAt}`,
310
+ `- cwd: ${meta.cwd}`,
311
+ `- workspace trusted: ${meta.workspaceTrusted}`,
312
+ ...(meta.piVersion ? [`- pi: ${meta.piVersion}`] : []),
313
+ '',
314
+ 'Precedence: env > workspace (trusted) > user > default.',
315
+ '',
316
+ ];
317
+ const escape = (text: string): string => text.replace(/\|/g, '\\|');
318
+ for (const plane of CONFIG_PLANES) {
319
+ const planeEntries = entries.filter((e) => e.knob.plane === plane.id);
320
+ lines.push(`## ${plane.id} — ${plane.title}`, '', `Files: ${plane.files.map((f) => `\`${f}\``).join(' , ')}`, '', `> ${plane.editHint}`, '');
321
+ if (planeEntries.length === 0) {
322
+ lines.push('(no value-carrying knobs; per-file plane)', '');
323
+ continue;
324
+ }
325
+ lines.push('| key | value | source | impact |', '|---|---|---|---|');
326
+ for (const entry of planeEntries) {
327
+ lines.push(`| \`${entry.knob.key}\` | ${escape(entry.value)} | ${sourceLabel(entry)} | ${escape(entry.knob.impact)} |`);
328
+ }
329
+ lines.push('');
330
+ }
331
+ return lines.join('\n');
332
+ }