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,464 @@
1
+ /**
2
+ * D100-D103 efficiency configuration: schema, validation, multi-tier resolution and the two files
3
+ * pier itself reads for it.
4
+ *
5
+ * Contract: unknown keys are rejected (typo guard — all issues collected in one pass); a project-level
6
+ * config requires `isProjectTrusted: true` (security boundary); workspace config shallow-replaces user
7
+ * config while env (`PI_HERDR_*` / `PIER_JEV_*`) wins over both; and a config that fails validation
8
+ * disables the affected mechanism with a one-line warning instead of crashing the session (an unknown
9
+ * top-level key disables every mechanism).
10
+ */
11
+
12
+ import { existsSync, readFileSync } from 'node:fs';
13
+ import { homedir } from 'node:os';
14
+ import { join } from 'node:path';
15
+
16
+ export interface OnlineContextCompactConfig {
17
+ enabled: boolean;
18
+ logEnabled: boolean;
19
+ cacheWriteReadRatio: number | 'auto';
20
+ keepRecentTokens?: number;
21
+ firstCompactionRequestScale: number;
22
+ subsequentCompactionMargin: number;
23
+ }
24
+
25
+ export interface ObservationPackConfig {
26
+ enabled: boolean;
27
+ logEnabled: boolean;
28
+ thresholdBytes: number;
29
+ fullSends: number;
30
+ recallChunkBytes: number;
31
+ excerptBytes: number;
32
+ }
33
+
34
+ export interface EvidencePreservingReducerConfig {
35
+ enabled: boolean;
36
+ logEnabled: boolean;
37
+ model?: string;
38
+ minBytes: number;
39
+ maxChars: number;
40
+ maxOutputTokens: number;
41
+ timeoutMs: number;
42
+ localOnly: boolean;
43
+ }
44
+
45
+ /**
46
+ * Jev decision layer (RFC docs/rfc-jev-integration.md): optional System One classification calls that
47
+ * upgrade hand-written heuristics; every call site fails open on any error, timeout, or low confidence.
48
+ */
49
+ export interface JevConfig {
50
+ enabled: boolean;
51
+ logEnabled: boolean;
52
+ /** API root override (relay/gateway); default https://api.typesafe.ai. */
53
+ baseUrl?: string;
54
+ /** Pinned versioned model id — aliases (jev-latest) move silently and would skew tuned thresholds. */
55
+ model: string;
56
+ /** Total per-call budget in ms (AbortController hard kill; the SDK has no total-budget mode). */
57
+ timeoutMs: number;
58
+ /** Minimum Choice/Score confidence to accept an answer; below = treat the question as unanswered. */
59
+ minConfidence: number;
60
+ /** Resolution: PIER_JEV_API_KEY env > this value > TYPESAFE_API_KEY env (SDK convention). */
61
+ apiKey?: string;
62
+ }
63
+
64
+ export interface EfficiencyConfig {
65
+ version: 1;
66
+ onlineContextCompact: OnlineContextCompactConfig;
67
+ observationPack: ObservationPackConfig;
68
+ evidencePreservingReducer: EvidencePreservingReducerConfig;
69
+ jev: JevConfig;
70
+ }
71
+
72
+ export const DEFAULT_EFFICIENCY_CONFIG: EfficiencyConfig = Object.freeze({
73
+ version: 1,
74
+ onlineContextCompact: Object.freeze({
75
+ enabled: false, logEnabled: false, cacheWriteReadRatio: 'auto', keepRecentTokens: 20000,
76
+ firstCompactionRequestScale: 2.0, subsequentCompactionMargin: 1.5,
77
+ }),
78
+ observationPack: Object.freeze({
79
+ enabled: false, logEnabled: false, thresholdBytes: 10240, fullSends: 2,
80
+ recallChunkBytes: 16384, excerptBytes: 1024,
81
+ }),
82
+ evidencePreservingReducer: Object.freeze({
83
+ enabled: false, logEnabled: false, minBytes: 4096, maxChars: 600000,
84
+ maxOutputTokens: 2048, timeoutMs: 5000, localOnly: false,
85
+ }),
86
+ jev: Object.freeze({ enabled: false, logEnabled: false, model: 'jev-1.13.0', timeoutMs: 2000, minConfidence: 0.6 }),
87
+ });
88
+
89
+ function isPlainObject(v: unknown): v is Record<string, unknown> {
90
+ return typeof v === 'object' && v !== null && !Array.isArray(v);
91
+ }
92
+
93
+ type FieldKind = 'bool' | 'int' | 'number' | 'ratio' | 'string' | 'optionalString';
94
+
95
+ interface FieldSpec {
96
+ readonly key: string;
97
+ readonly kind: FieldKind;
98
+ /** Lower bound for `int`/`number`; also printed in the issue message. */
99
+ readonly min?: number;
100
+ readonly max?: number;
101
+ /** Issue text for a bound that must not be printed as a plain JS number ("1.0"). */
102
+ readonly minText?: string;
103
+ }
104
+
105
+ /** A section is `{enabled, ...fields}`; field order fixes the order of reported issues. */
106
+ interface SectionSpec {
107
+ readonly name: 'onlineContextCompact' | 'observationPack' | 'evidencePreservingReducer' | 'jev';
108
+ readonly fields: readonly FieldSpec[];
109
+ }
110
+
111
+ const SECTIONS: readonly SectionSpec[] = [
112
+ { name: 'onlineContextCompact', fields: [
113
+ { key: 'enabled', kind: 'bool' },
114
+ { key: 'logEnabled', kind: 'bool' },
115
+ { key: 'cacheWriteReadRatio', kind: 'ratio' },
116
+ { key: 'keepRecentTokens', kind: 'int', min: 1000 },
117
+ { key: 'firstCompactionRequestScale', kind: 'number', min: 1, minText: '1.0' },
118
+ { key: 'subsequentCompactionMargin', kind: 'number', min: 1, minText: '1.0' },
119
+ ] },
120
+ { name: 'observationPack', fields: [
121
+ { key: 'enabled', kind: 'bool' },
122
+ { key: 'logEnabled', kind: 'bool' },
123
+ { key: 'thresholdBytes', kind: 'int', min: 1024 },
124
+ { key: 'fullSends', kind: 'int', min: 1 },
125
+ { key: 'recallChunkBytes', kind: 'int', min: 1024 },
126
+ { key: 'excerptBytes', kind: 'int', min: 128 },
127
+ ] },
128
+ { name: 'evidencePreservingReducer', fields: [
129
+ { key: 'enabled', kind: 'bool' },
130
+ { key: 'logEnabled', kind: 'bool' },
131
+ { key: 'model', kind: 'optionalString' },
132
+ { key: 'minBytes', kind: 'int', min: 512 },
133
+ { key: 'maxChars', kind: 'int', min: 1000 },
134
+ { key: 'maxOutputTokens', kind: 'int', min: 128 },
135
+ { key: 'timeoutMs', kind: 'int', min: 500 },
136
+ { key: 'localOnly', kind: 'bool' },
137
+ ] },
138
+ { name: 'jev', fields: [
139
+ { key: 'enabled', kind: 'bool' },
140
+ { key: 'logEnabled', kind: 'bool' },
141
+ { key: 'baseUrl', kind: 'optionalString' },
142
+ { key: 'model', kind: 'string' },
143
+ { key: 'timeoutMs', kind: 'int', min: 500 },
144
+ { key: 'minConfidence', kind: 'number', min: 0, max: 1 },
145
+ { key: 'apiKey', kind: 'optionalString' },
146
+ ] },
147
+ ];
148
+
149
+ function issueFor(section: string, field: FieldSpec): string {
150
+ const path = `${section}.${field.key}`;
151
+ switch (field.kind) {
152
+ case 'bool':
153
+ return `${path} 必须是 boolean`;
154
+ case 'int':
155
+ return `${path} 必须是 >= ${field.min} 的整数`;
156
+ case 'number':
157
+ return field.max === undefined
158
+ ? `${path} 必须是 >= ${field.minText ?? field.min} 的数字`
159
+ : `${path} 必须是 ${field.min} 到 ${field.max} 之间的数字`;
160
+ case 'ratio':
161
+ return `${path} 必须是 "auto" 或非负有限数`;
162
+ default:
163
+ return `${path} 必须是非空字符串`;
164
+ }
165
+ }
166
+
167
+ function applyField(target: Record<string, unknown>, field: FieldSpec, raw: unknown): boolean {
168
+ if (raw === undefined) return true;
169
+ const min = field.min ?? Number.NEGATIVE_INFINITY;
170
+ switch (field.kind) {
171
+ case 'bool':
172
+ if (typeof raw !== 'boolean') return false;
173
+ break;
174
+ case 'int':
175
+ if (typeof raw !== 'number' || !Number.isInteger(raw) || raw < min) return false;
176
+ break;
177
+ case 'number':
178
+ if (typeof raw !== 'number' || !Number.isFinite(raw) || raw < min) return false;
179
+ if (field.max !== undefined && raw > field.max) return false;
180
+ break;
181
+ case 'ratio':
182
+ if (raw !== 'auto' && (typeof raw !== 'number' || !Number.isFinite(raw) || raw < 0)) return false;
183
+ break;
184
+ case 'string':
185
+ if (typeof raw !== 'string' || !raw.trim()) return false;
186
+ target[field.key] = raw.trim();
187
+ return true;
188
+ case 'optionalString':
189
+ if (raw === '' || raw === null) {
190
+ target[field.key] = undefined;
191
+ return true;
192
+ }
193
+ if (typeof raw !== 'string' || !raw.trim()) return false;
194
+ target[field.key] = raw.trim();
195
+ return true;
196
+ }
197
+ target[field.key] = raw;
198
+ return true;
199
+ }
200
+
201
+ function defaults(): EfficiencyConfig {
202
+ return {
203
+ version: 1,
204
+ onlineContextCompact: { ...DEFAULT_EFFICIENCY_CONFIG.onlineContextCompact },
205
+ observationPack: { ...DEFAULT_EFFICIENCY_CONFIG.observationPack },
206
+ evidencePreservingReducer: { ...DEFAULT_EFFICIENCY_CONFIG.evidencePreservingReducer },
207
+ jev: { ...DEFAULT_EFFICIENCY_CONFIG.jev },
208
+ };
209
+ }
210
+
211
+ export interface ValidateConfigResult {
212
+ ok: boolean;
213
+ config: EfficiencyConfig;
214
+ issues: string[];
215
+ }
216
+
217
+ export function validateEfficiencyConfig(raw: unknown): ValidateConfigResult {
218
+ const config = defaults();
219
+ if (!isPlainObject(raw)) {
220
+ for (const section of SECTIONS) config[section.name].enabled = false;
221
+ return { ok: false, config, issues: ['efficiency config 必须是 JSON 对象'] };
222
+ }
223
+
224
+ const issues: string[] = [];
225
+ for (const key of Object.keys(raw)) {
226
+ if (key !== 'version' && !SECTIONS.some((s) => s.name === key)) issues.push(`未知顶层配置项: "${key}"`);
227
+ }
228
+ if (raw.version !== undefined && raw.version !== 1) {
229
+ issues.push(`version 必须是 1 (当前: ${JSON.stringify(raw.version)})`);
230
+ }
231
+ const topLevelInvalid = issues.length > 0;
232
+
233
+ for (const section of SECTIONS) {
234
+ const issueCountBefore = issues.length;
235
+ const target = config[section.name] as unknown as Record<string, unknown>;
236
+ const rawSection = raw[section.name];
237
+ if (rawSection !== undefined) {
238
+ if (!isPlainObject(rawSection)) {
239
+ issues.push(`${section.name} 必须是 JSON 对象`);
240
+ } else {
241
+ for (const key of Object.keys(rawSection)) {
242
+ if (!section.fields.some((f) => f.key === key)) issues.push(`${section.name} 未知配置项: "${key}"`);
243
+ }
244
+ for (const field of section.fields) {
245
+ if (!applyField(target, field, rawSection[field.key])) issues.push(issueFor(section.name, field));
246
+ }
247
+ }
248
+ }
249
+ // Fail-open: an invalid section (or an invalid top level) can never enable a mechanism.
250
+ if (topLevelInvalid || issues.length > issueCountBefore) config[section.name].enabled = false;
251
+ }
252
+
253
+ return { ok: issues.length === 0, config, issues };
254
+ }
255
+
256
+ function parseEnvBool(val: string): boolean | undefined {
257
+ const s = val.trim().toLowerCase();
258
+ if (s === '1' || s === 'true' || s === 'on') return true;
259
+ if (s === '0' || s === 'false' || s === 'off') return false;
260
+ return undefined;
261
+ }
262
+
263
+ interface EnvOverride {
264
+ readonly env: string;
265
+ readonly section: SectionSpec['name'];
266
+ readonly key: string;
267
+ readonly kind: 'bool' | 'string' | 'ratio' | 'int' | 'unit';
268
+ readonly min?: number;
269
+ /** Set only when the config left the key unset (foreign-convention fallback). */
270
+ readonly fallbackOnly?: boolean;
271
+ }
272
+
273
+ /** Highest-precedence layer; an empty value counts as unset everywhere. */
274
+ const ENV_OVERRIDES: readonly EnvOverride[] = [
275
+ { env: 'PI_HERDR_COMPACT_ENABLE', section: 'onlineContextCompact', key: 'enabled', kind: 'bool' },
276
+ { env: 'PI_HERDR_COMPACT_LOG', section: 'onlineContextCompact', key: 'logEnabled', kind: 'bool' },
277
+ { env: 'PI_HERDR_CACHE_RATIO', section: 'onlineContextCompact', key: 'cacheWriteReadRatio', kind: 'ratio' },
278
+ { env: 'PI_HERDR_OBS_PACK_ENABLE', section: 'observationPack', key: 'enabled', kind: 'bool' },
279
+ { env: 'PI_HERDR_OBS_PACK_LOG', section: 'observationPack', key: 'logEnabled', kind: 'bool' },
280
+ { env: 'PI_HERDR_REDUCER_ENABLE', section: 'evidencePreservingReducer', key: 'enabled', kind: 'bool' },
281
+ { env: 'PI_HERDR_REDUCER_LOG', section: 'evidencePreservingReducer', key: 'logEnabled', kind: 'bool' },
282
+ { env: 'PI_HERDR_REDUCER_MODEL', section: 'evidencePreservingReducer', key: 'model', kind: 'string' },
283
+ // jev has no legacy spelling: PIER_JEV_* is canonical from the start.
284
+ { env: 'PIER_JEV_ENABLE', section: 'jev', key: 'enabled', kind: 'bool' },
285
+ { env: 'PIER_JEV_LOG', section: 'jev', key: 'logEnabled', kind: 'bool' },
286
+ { env: 'PIER_JEV_MODEL', section: 'jev', key: 'model', kind: 'string' },
287
+ { env: 'PIER_JEV_BASE_URL', section: 'jev', key: 'baseUrl', kind: 'string' },
288
+ { env: 'PIER_JEV_TIMEOUT_MS', section: 'jev', key: 'timeoutMs', kind: 'int', min: 500 },
289
+ { env: 'PIER_JEV_MIN_CONFIDENCE', section: 'jev', key: 'minConfidence', kind: 'unit' },
290
+ { env: 'PIER_JEV_API_KEY', section: 'jev', key: 'apiKey', kind: 'string' },
291
+ // TYPESAFE_API_KEY is a foreign (SDK) convention, so it ranks below the config file too.
292
+ { env: 'TYPESAFE_API_KEY', section: 'jev', key: 'apiKey', kind: 'string', fallbackOnly: true },
293
+ ];
294
+
295
+ function applyEnvOverrides(
296
+ config: EfficiencyConfig,
297
+ env: Record<string, string | undefined>,
298
+ warn: (message: string) => void,
299
+ ): void {
300
+ for (const override of ENV_OVERRIDES) {
301
+ const raw = env[override.env];
302
+ if (raw === undefined || raw === '') continue;
303
+ const target = config[override.section] as unknown as Record<string, unknown>;
304
+ if (override.fallbackOnly && target[override.key] !== undefined) continue;
305
+
306
+ if (override.kind === 'bool') {
307
+ const parsed = parseEnvBool(raw);
308
+ if (parsed !== undefined) target[override.key] = parsed;
309
+ continue;
310
+ }
311
+ if (override.kind === 'string') {
312
+ if (raw.trim()) target[override.key] = raw.trim();
313
+ continue;
314
+ }
315
+ if (override.kind === 'ratio' && raw.trim().toLowerCase() === 'auto') {
316
+ target[override.key] = 'auto';
317
+ continue;
318
+ }
319
+ const num = Number(raw);
320
+ const valid =
321
+ override.kind === 'ratio' ? Number.isFinite(num) && num >= 0
322
+ : override.kind === 'unit' ? Number.isFinite(num) && num >= 0 && num <= 1
323
+ : Number.isInteger(num) && num >= (override.min ?? 0);
324
+ if (valid) {
325
+ target[override.key] = num;
326
+ continue;
327
+ }
328
+ const requirement =
329
+ override.kind === 'unit' ? '(需 0 到 1)'
330
+ : override.kind === 'int' ? `(需 >= ${override.min} 的整数)`
331
+ : '';
332
+ warn(`无效环境变量 ${override.env}="${raw}"${requirement},忽略`);
333
+ }
334
+ }
335
+
336
+ export interface ResolveConfigOptions {
337
+ workspaceConfig?: unknown;
338
+ userConfig?: unknown;
339
+ env?: Record<string, string | undefined>;
340
+ isProjectTrusted?: boolean;
341
+ onWarning?: (message: string) => void;
342
+ }
343
+
344
+ export function resolveEfficiencyConfig(opts: ResolveConfigOptions = {}): EfficiencyConfig {
345
+ const env = opts.env ?? process.env;
346
+ const warn = opts.onWarning ?? ((msg: string) => console.warn(`[pi-herdr] efficiency config warning: ${msg}`));
347
+ const isTrusted = opts.isProjectTrusted ?? false;
348
+
349
+ // An untrusted project's file is ignored wholesale (never merged with the user layer).
350
+ let baseRaw: unknown = opts.userConfig;
351
+ if (opts.workspaceConfig !== undefined) {
352
+ if (isTrusted) baseRaw = opts.workspaceConfig;
353
+ else warn('工作区能效配置未被信任(isProjectTrusted=false),已安全忽略工作区配置');
354
+ }
355
+
356
+ let resolved = defaults();
357
+ if (baseRaw !== undefined) {
358
+ const validated = validateEfficiencyConfig(baseRaw);
359
+ if (!validated.ok) warn(`配置存在校验问题,已安全回退默认值: ${validated.issues.join('; ')}`);
360
+ resolved = validated.config;
361
+ }
362
+
363
+ applyEnvOverrides(resolved, env, warn);
364
+ return resolved;
365
+ }
366
+
367
+ function defaultUserEfficiencyConfigDir(): string {
368
+ return join(homedir(), '.pi', 'agent', 'herdr-pi');
369
+ }
370
+
371
+ export function defaultWorkspaceEfficiencyConfigFile(cwd: string): string {
372
+ return join(cwd, '.pi-herdr', 'config.json');
373
+ }
374
+
375
+ export function defaultUserEfficiencyConfigFile(): string {
376
+ return join(defaultUserEfficiencyConfigDir(), 'config.json');
377
+ }
378
+
379
+ interface PiCompactionSettings {
380
+ enabled?: boolean;
381
+ keepRecentTokens?: number;
382
+ }
383
+
384
+ function readCompactionSettings(filePath: string): PiCompactionSettings {
385
+ const parsed = readJsonConfig(filePath);
386
+ if (!isPlainObject(parsed) || !isPlainObject(parsed.compaction)) return {};
387
+ const { compaction } = parsed;
388
+ const result: PiCompactionSettings = {};
389
+ if (typeof compaction.enabled === 'boolean') result.enabled = compaction.enabled;
390
+ if (typeof compaction.keepRecentTokens === 'number') result.keepRecentTokens = compaction.keepRecentTokens;
391
+ return result;
392
+ }
393
+
394
+ function readJsonConfig(filePath: string, onError?: (error: unknown) => void): unknown {
395
+ if (!existsSync(filePath)) return undefined;
396
+ try {
397
+ return JSON.parse(readFileSync(filePath, 'utf8')) as unknown;
398
+ } catch (error) {
399
+ onError?.(error);
400
+ return undefined;
401
+ }
402
+ }
403
+
404
+ /** Pi's own compaction settings: global, plus the project file when the project is trusted. */
405
+ export function loadPiNativeCompactionSettings(opts: {
406
+ cwd?: string;
407
+ isProjectTrusted?: boolean;
408
+ agentDir?: string;
409
+ } = {}): PiCompactionSettings {
410
+ const cwd = opts.cwd ?? process.cwd();
411
+ const agentDir = opts.agentDir ?? process.env.PI_CODING_AGENT_DIR ?? join(homedir(), '.pi', 'agent');
412
+ const globalSettings = readCompactionSettings(join(agentDir, 'settings.json'));
413
+ if (!opts.isProjectTrusted) return globalSettings;
414
+ return { ...globalSettings, ...readCompactionSettings(join(cwd, '.pi', 'settings.json')) };
415
+ }
416
+
417
+ export function loadEfficiencyConfigFromDisk(opts: {
418
+ cwd?: string;
419
+ isProjectTrusted?: boolean;
420
+ env?: Record<string, string | undefined>;
421
+ onWarning?: (msg: string) => void;
422
+ /** Override the Pi agent dir used to read native `settings.json` (tests/isolation). */
423
+ agentDir?: string;
424
+ userConfigPath?: string;
425
+ } = {}): EfficiencyConfig {
426
+ const cwd = opts.cwd ?? process.cwd();
427
+ const wsPath = defaultWorkspaceEfficiencyConfigFile(cwd);
428
+ const userPath = opts.userConfigPath ?? defaultUserEfficiencyConfigFile();
429
+ const warnFile = (label: string, filePath: string, error: unknown): void => {
430
+ opts.onWarning?.(`${label} ${filePath}: ${error instanceof Error ? error.message : String(error)}`);
431
+ };
432
+ const workspaceConfig = readJsonConfig(wsPath, (error) => warnFile('无法解析工作区能效配置文件', wsPath, error));
433
+ const userConfig = readJsonConfig(userPath, (error) => warnFile('无法解析用户能效配置文件', userPath, error));
434
+ const resolved = resolveEfficiencyConfig({
435
+ workspaceConfig,
436
+ userConfig,
437
+ isProjectTrusted: opts.isProjectTrusted,
438
+ env: opts.env,
439
+ onWarning: opts.onWarning,
440
+ });
441
+
442
+ const piSettings = loadPiNativeCompactionSettings({
443
+ cwd,
444
+ isProjectTrusted: opts.isProjectTrusted,
445
+ agentDir: opts.agentDir,
446
+ });
447
+
448
+ // pi's global compaction switch wins over an explicitly enabled OCC — only an env force overrides it.
449
+ const env = opts.env ?? process.env;
450
+ if (piSettings.enabled === false && !env.PI_HERDR_COMPACT_ENABLE) {
451
+ resolved.onlineContextCompact.enabled = false;
452
+ }
453
+ const hasExplicitKeepRecent = [opts.isProjectTrusted ? workspaceConfig : undefined, userConfig].some(
454
+ (config) =>
455
+ isPlainObject(config)
456
+ && isPlainObject(config.onlineContextCompact)
457
+ && config.onlineContextCompact.keepRecentTokens !== undefined,
458
+ );
459
+ // pi's keepRecentTokens is inherited only while the efficiency config does not set its own.
460
+ if (!hasExplicitKeepRecent && typeof piSettings.keepRecentTokens === 'number') {
461
+ resolved.onlineContextCompact.keepRecentTokens = piSettings.keepRecentTokens;
462
+ }
463
+ return resolved;
464
+ }