pi-observational-memory 2.3.0 → 2.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/compaction.ts CHANGED
@@ -1,12 +1,15 @@
1
1
  import { agentLoop, type AgentContext, type AgentLoopConfig, type AgentTool } from "@mariozechner/pi-agent-core";
2
2
  import { Type, type Message, type Model } from "@mariozechner/pi-ai";
3
3
  import type { Static } from "@sinclair/typebox";
4
+ import { hashId } from "./ids.js";
4
5
  import { observationsToPromptLines } from "./observer.js";
5
- import { buildPrunerPassGuidance, CONTEXT_USAGE_INSTRUCTIONS, PRUNER_SYSTEM, REFLECTOR_SYSTEM } from "./prompts.js";
6
+ import { buildPrunerPassGuidance, buildReflectorPassGuidance, CONTEXT_USAGE_INSTRUCTIONS, PRUNER_SYSTEM, REFLECTOR_SYSTEM } from "./prompts.js";
6
7
  import { truncateRecordContent } from "./serialize.js";
7
8
  import { estimateStringTokens } from "./tokens.js";
8
- import type { ObservationRecord, Reflection } from "./types.js";
9
+ import { reflectionContent, reflectionToPromptLine } from "./types.js";
10
+ import type { MemoryReflection, ObservationRecord, ReflectionRecord } from "./types.js";
9
11
 
12
+ const REFLECTOR_MAX_PASSES = 3;
10
13
  const PRUNER_MAX_PASSES = 5;
11
14
  const PRUNER_TARGET_RATIO = 0.8;
12
15
 
@@ -19,79 +22,341 @@ interface LlmArgs {
19
22
  apiKey: string;
20
23
  headers?: Record<string, string>;
21
24
  signal?: AbortSignal;
25
+ agentLoop?: typeof agentLoop;
22
26
  }
23
27
 
24
- function joinReflectionsOrEmpty(items: Reflection[]): string {
25
- return items.length ? items.join("\n") : "(none yet)";
28
+ function joinReflectionsOrEmpty(items: MemoryReflection[]): string {
29
+ return items.length ? items.map(reflectionToPromptLine).join("\n") : "(none yet)";
26
30
  }
27
31
 
28
32
  function joinObservationsOrEmpty(items: ObservationRecord[]): string {
29
33
  return items.length ? observationsToPromptLines(items).join("\n") : "(none yet)";
30
34
  }
31
35
 
36
+ export type ObservationCoverageTag = "uncited" | "cited" | "reinforced";
37
+
38
+ export function deriveObservationCoverageTags(
39
+ reflections: MemoryReflection[],
40
+ observations: ObservationRecord[],
41
+ ): Map<string, ObservationCoverageTag> {
42
+ const activeIds = new Set(observations.map((o) => o.id));
43
+ const counts = new Map<string, number>();
44
+ for (const observation of observations) counts.set(observation.id, 0);
45
+
46
+ for (const reflection of reflections) {
47
+ if (typeof reflection === "string" || reflection.legacy === true) continue;
48
+ const citedActiveIds = new Set(reflection.supportingObservationIds.filter((id) => activeIds.has(id)));
49
+ for (const id of citedActiveIds) counts.set(id, (counts.get(id) ?? 0) + 1);
50
+ }
51
+
52
+ const tags = new Map<string, ObservationCoverageTag>();
53
+ for (const observation of observations) {
54
+ const count = counts.get(observation.id) ?? 0;
55
+ tags.set(observation.id, count === 0 ? "uncited" : count >= 4 ? "reinforced" : "cited");
56
+ }
57
+ return tags;
58
+ }
59
+
60
+ export function renderObservationsForPrunerPrompt(
61
+ observations: ObservationRecord[],
62
+ coverageTags: ReadonlyMap<string, ObservationCoverageTag>,
63
+ ): string {
64
+ if (observations.length === 0) return "(none yet)";
65
+ return observations
66
+ .map((observation) => {
67
+ const tag = coverageTags.get(observation.id) ?? "uncited";
68
+ return `[${observation.id}] ${observation.timestamp} [${observation.relevance}] [coverage: ${tag}] ${observation.content}`;
69
+ })
70
+ .join("\n");
71
+ }
72
+
73
+ export function migrateLegacyReflections(reflections: MemoryReflection[]): MemoryReflection[] {
74
+ const migrated: MemoryReflection[] = [];
75
+ const contentToIndex = new Map<string, number>();
76
+
77
+ for (const reflection of reflections) {
78
+ const rawContent = reflectionContent(reflection).trim();
79
+ const normalizedContent = typeof reflection === "string" ? rawContent.replace(/\s+/g, " ") : rawContent;
80
+ if (!normalizedContent) {
81
+ migrated.push(reflection);
82
+ continue;
83
+ }
84
+ const content = truncateRecordContent(normalizedContent);
85
+
86
+ const existingIndex = contentToIndex.get(content);
87
+ if (existingIndex !== undefined) {
88
+ const existing = migrated[existingIndex];
89
+ if (typeof existing !== "string" && existing.legacy === true && typeof reflection !== "string" && reflection.legacy !== true) {
90
+ migrated[existingIndex] = reflection;
91
+ }
92
+ continue;
93
+ }
94
+
95
+ if (typeof reflection !== "string") {
96
+ migrated.push(reflection);
97
+ contentToIndex.set(content, migrated.length - 1);
98
+ continue;
99
+ }
100
+
101
+ migrated.push({
102
+ id: hashId(content),
103
+ content,
104
+ supportingObservationIds: [],
105
+ legacy: true,
106
+ });
107
+ contentToIndex.set(content, migrated.length - 1);
108
+ }
109
+
110
+ return migrated;
111
+ }
112
+
32
113
  const RecordReflectionsSchema = Type.Object({
33
114
  reflections: Type.Array(
34
- Type.String({
35
- minLength: 1,
36
- description: "Single-line plain prose reflection. No markdown, no tags, no timestamp, no bullets.",
115
+ Type.Object({
116
+ content: Type.String({
117
+ minLength: 1,
118
+ description: "Single-line plain prose reflection. No markdown, no tags, no timestamp, no bullets.",
119
+ }),
120
+ supportingObservationIds: Type.Array(
121
+ Type.String({
122
+ pattern: "^[a-f0-9]{12}$",
123
+ description: "Exact observation id from the current-observations list that supports this reflection.",
124
+ }),
125
+ {
126
+ minItems: 1,
127
+ description:
128
+ "Smallest exact set of current observation ids that directly support this reflection. " +
129
+ "Use only ids shown in the current observations list; never invent ids.",
130
+ },
131
+ ),
37
132
  }),
38
133
  {
39
134
  minItems: 1,
40
- description: "Batch of new reflections. Each string is one reflection.",
135
+ description: "Batch of new reflection proposals with their supporting observation ids.",
41
136
  },
42
137
  ),
43
138
  });
44
139
 
45
140
  type RecordReflectionsArgs = Static<typeof RecordReflectionsSchema>;
46
141
 
47
- export async function runReflector(
142
+ export function normalizeSupportingObservationIds(
143
+ supportingObservationIds: readonly string[] | undefined,
144
+ allowedObservationIds: readonly string[],
145
+ ): string[] | undefined {
146
+ if (!supportingObservationIds || supportingObservationIds.length === 0) return undefined;
147
+ const allowedOrder = new Map<string, number>();
148
+ for (let i = 0; i < allowedObservationIds.length; i++) {
149
+ if (!allowedOrder.has(allowedObservationIds[i])) allowedOrder.set(allowedObservationIds[i], i);
150
+ }
151
+
152
+ const seen = new Set<string>();
153
+ for (const id of supportingObservationIds) {
154
+ if (!allowedOrder.has(id)) return undefined;
155
+ seen.add(id);
156
+ }
157
+ if (seen.size === 0) return undefined;
158
+ return Array.from(seen).sort((a, b) => (allowedOrder.get(a) ?? 0) - (allowedOrder.get(b) ?? 0));
159
+ }
160
+
161
+ export interface ReflectorPassContext {
162
+ pass: number;
163
+ maxPasses: number;
164
+ minSupportingObservationIds: number;
165
+ }
166
+
167
+ export type ReflectionProposal = {
168
+ content: string;
169
+ supportingObservationIds?: readonly string[];
170
+ };
171
+
172
+ export interface ApplyReflectionProposalsResult {
173
+ reflections: MemoryReflection[];
174
+ accepted: number;
175
+ added: number;
176
+ merged: number;
177
+ promoted: number;
178
+ duplicates: number;
179
+ unsupported: number;
180
+ }
181
+
182
+ function reflectorPassContext(pass: number): ReflectorPassContext {
183
+ return {
184
+ pass,
185
+ maxPasses: REFLECTOR_MAX_PASSES,
186
+ minSupportingObservationIds: pass === 1 ? 2 : 1,
187
+ };
188
+ }
189
+
190
+ function reflectionContentKey(reflection: MemoryReflection): string {
191
+ return reflectionContent(reflection).trim();
192
+ }
193
+
194
+ function normalizeReflectionProposalContent(content: string): string | undefined {
195
+ const normalized = truncateRecordContent(content.trim());
196
+ if (!normalized || /[\r\n]/.test(normalized)) return undefined;
197
+ return normalized;
198
+ }
199
+
200
+ function mergeSupportingObservationIds(
201
+ existing: readonly string[],
202
+ incoming: readonly string[],
203
+ allowedObservationIds: readonly string[],
204
+ ): string[] | undefined {
205
+ const allowed = new Set(allowedObservationIds);
206
+ const historicalExisting = existing.filter((id) => !allowed.has(id));
207
+ const currentExisting = existing.filter((id) => allowed.has(id));
208
+ const normalizedCurrent = normalizeSupportingObservationIds([...currentExisting, ...incoming], allowedObservationIds);
209
+ if (!normalizedCurrent) return undefined;
210
+ return [...historicalExisting, ...normalizedCurrent];
211
+ }
212
+
213
+ export function renderReflectionsForReflectorPrompt(reflections: MemoryReflection[]): string {
214
+ return joinReflectionsOrEmpty(reflections);
215
+ }
216
+
217
+ export function applyReflectionProposals(
218
+ reflections: MemoryReflection[],
219
+ proposals: readonly ReflectionProposal[],
220
+ allowedObservationIds: readonly string[],
221
+ passContext: Pick<ReflectorPassContext, "minSupportingObservationIds">,
222
+ ): ApplyReflectionProposalsResult {
223
+ const next = [...reflections];
224
+ let accepted = 0;
225
+ let added = 0;
226
+ let merged = 0;
227
+ let promoted = 0;
228
+ let duplicates = 0;
229
+ let unsupported = 0;
230
+
231
+ for (const proposal of proposals) {
232
+ const content = normalizeReflectionProposalContent(proposal.content);
233
+ if (!content) {
234
+ unsupported++;
235
+ continue;
236
+ }
237
+ const supportingObservationIds = normalizeSupportingObservationIds(
238
+ proposal.supportingObservationIds,
239
+ allowedObservationIds,
240
+ );
241
+ if (!supportingObservationIds || supportingObservationIds.length < passContext.minSupportingObservationIds) {
242
+ unsupported++;
243
+ continue;
244
+ }
245
+
246
+ const existingIndex = next.findIndex((reflection) => reflectionContentKey(reflection) === content);
247
+ if (existingIndex >= 0) {
248
+ const existing = next[existingIndex];
249
+ if (typeof existing === "string") {
250
+ next[existingIndex] = {
251
+ id: hashId(content),
252
+ content,
253
+ supportingObservationIds,
254
+ };
255
+ accepted++;
256
+ promoted++;
257
+ continue;
258
+ }
259
+
260
+ const mergedSupport = mergeSupportingObservationIds(
261
+ existing.supportingObservationIds,
262
+ supportingObservationIds,
263
+ allowedObservationIds,
264
+ );
265
+ if (!mergedSupport) {
266
+ unsupported++;
267
+ continue;
268
+ }
269
+ const hasNewSupport = mergedSupport.length !== existing.supportingObservationIds.length;
270
+ if (existing.legacy === true) {
271
+ next[existingIndex] = {
272
+ id: existing.id,
273
+ content: existing.content,
274
+ supportingObservationIds: mergedSupport,
275
+ };
276
+ accepted++;
277
+ promoted++;
278
+ continue;
279
+ }
280
+ if (hasNewSupport) {
281
+ next[existingIndex] = {
282
+ ...existing,
283
+ supportingObservationIds: mergedSupport,
284
+ };
285
+ accepted++;
286
+ merged++;
287
+ } else {
288
+ duplicates++;
289
+ }
290
+ continue;
291
+ }
292
+
293
+ next.push({
294
+ id: hashId(content),
295
+ content,
296
+ supportingObservationIds,
297
+ });
298
+ accepted++;
299
+ added++;
300
+ }
301
+
302
+ return { reflections: next, accepted, added, merged, promoted, duplicates, unsupported };
303
+ }
304
+
305
+ async function runReflectorPass(
48
306
  args: LlmArgs,
49
- reflections: Reflection[],
307
+ reflections: MemoryReflection[],
50
308
  observations: ObservationRecord[],
51
- ): Promise<Reflection[]> {
52
- const existing = new Set(reflections.map((r) => r.trim()));
53
- const added = new Set<string>();
309
+ passContext: ReflectorPassContext,
310
+ ): Promise<{ reflections: MemoryReflection[]; failed: boolean }> {
311
+ const allowedObservationIds = observations.map((o) => o.id);
312
+ let currentReflections = reflections;
54
313
 
55
314
  const recordTool: AgentTool<typeof RecordReflectionsSchema> = {
56
315
  name: "record_reflections",
57
316
  label: "Record reflections",
58
317
  description:
59
- "Record a batch of new reflections crystallized from the observation pool. " +
60
- "May be called multiple times. Stop calling when nothing more is stable enough to crystallize, " +
318
+ "Record a batch of reflections crystallized from the observation pool. " +
319
+ "May be called multiple times. Stop calling when nothing more is stable enough to crystallize for this pass, " +
61
320
  "then emit a short plain-text confirmation.",
62
321
  parameters: RecordReflectionsSchema,
63
322
  execute: async (_id, params: RecordReflectionsArgs) => {
64
- let accepted = 0;
65
- let duplicates = 0;
66
- for (const r of params.reflections) {
67
- const content = truncateRecordContent(r.trim());
68
- if (!content) continue;
69
- if (existing.has(content) || added.has(content)) {
70
- duplicates++;
71
- continue;
72
- }
73
- added.add(content);
74
- accepted++;
75
- }
323
+ const result = applyReflectionProposals(
324
+ currentReflections,
325
+ params.reflections,
326
+ allowedObservationIds,
327
+ passContext,
328
+ );
329
+ currentReflections = result.reflections;
76
330
  const parts: string[] = [];
77
- parts.push(`Recorded ${accepted} new reflection${accepted === 1 ? "" : "s"}.`);
78
- if (duplicates) parts.push(`${duplicates} duplicate${duplicates === 1 ? "" : "s"} skipped.`);
79
- parts.push(`Total new this run: ${added.size}.`);
80
- parts.push("Call record_reflections again if more should be crystallized; otherwise stop and emit a short plain-text confirmation.");
331
+ parts.push(`Accepted ${result.accepted} reflection proposal${result.accepted === 1 ? "" : "s"}.`);
332
+ if (result.added) parts.push(`${result.added} new.`);
333
+ if (result.merged) parts.push(`${result.merged} merged into existing reflections.`);
334
+ if (result.promoted) parts.push(`${result.promoted} promoted from legacy/no-provenance memory.`);
335
+ if (result.duplicates) parts.push(`${result.duplicates} duplicate/no-op proposal${result.duplicates === 1 ? "" : "s"} skipped.`);
336
+ if (result.unsupported) {
337
+ parts.push(
338
+ `${result.unsupported} unsupported proposal${result.unsupported === 1 ? "" : "s"} rejected for invalid supporting observation ids or this pass's minimum support requirement.`,
339
+ );
340
+ }
341
+ parts.push("Call record_reflections again if more should be crystallized for this pass; otherwise stop and emit a short plain-text confirmation.");
81
342
  return {
82
343
  content: [{ type: "text", text: parts.join(" ") }],
83
- details: { accepted, duplicates, total: added.size },
344
+ details: result,
84
345
  };
85
346
  },
86
347
  };
87
348
 
349
+ const passGuidance = buildReflectorPassGuidance(passContext.pass, passContext.maxPasses);
88
350
  const userText = `CURRENT REFLECTIONS:
89
- ${joinReflectionsOrEmpty(reflections)}
351
+ ${renderReflectionsForReflectorPrompt(reflections)}
90
352
 
91
353
  CURRENT OBSERVATIONS:
92
354
  ${joinObservationsOrEmpty(observations)}
93
355
 
94
- Crystallize new long-lived reflections from the observation pool. Call record_reflections with batches of new reflections. You may call the tool multiple times as you reason through the pool. Do not restate reflections already in the current reflections list. When done, stop calling the tool and emit a short plain-text confirmation.`;
356
+ REFLECTOR PASS GUIDANCE:
357
+ ${passGuidance}
358
+
359
+ Crystallize long-lived reflections from the full observation pool for this pass. Call record_reflections with batches of reflection proposals, each with the exact supporting observation ids. You may call the tool multiple times as you reason through the pool. To strengthen or promote an existing reflection, repeat the exact existing reflection content with valid supporting observation ids. Do not lightly reword existing reflections. When done, stop calling the tool and emit a short plain-text confirmation.`;
95
360
 
96
361
  const prompts: Message[] = [
97
362
  {
@@ -119,16 +384,33 @@ Crystallize new long-lived reflections from the observation pool. Call record_re
119
384
  };
120
385
 
121
386
  try {
122
- const stream = agentLoop(prompts, context, config, args.signal);
387
+ const loop = args.agentLoop ?? agentLoop;
388
+ const stream = loop(prompts, context, config, args.signal);
123
389
  for await (const _event of stream) {
124
- // Drain events; the tool's execute already collects reflections.
390
+ // Drain events; the tool's execute already updates reflections.
125
391
  }
126
392
  await stream.result();
127
393
  } catch {
128
- // Salvage any reflections accepted before the error; downstream pruner still runs.
394
+ return { reflections: currentReflections, failed: true };
395
+ }
396
+
397
+ return { reflections: currentReflections, failed: false };
398
+ }
399
+
400
+ export async function runReflector(
401
+ args: LlmArgs,
402
+ reflections: MemoryReflection[],
403
+ observations: ObservationRecord[],
404
+ ): Promise<MemoryReflection[]> {
405
+ let currentReflections = reflections;
406
+
407
+ for (let pass = 1; pass <= REFLECTOR_MAX_PASSES; pass++) {
408
+ const result = await runReflectorPass(args, currentReflections, observations, reflectorPassContext(pass));
409
+ currentReflections = result.reflections;
410
+ if (result.failed) break;
129
411
  }
130
412
 
131
- return Array.from(added);
413
+ return currentReflections;
132
414
  }
133
415
 
134
416
  export interface PrunerResult {
@@ -161,6 +443,7 @@ interface PrunerPassContext {
161
443
  deltaTokens: number;
162
444
  pass: number;
163
445
  maxPasses: number;
446
+ coverageTags: ReadonlyMap<string, ObservationCoverageTag>;
164
447
  }
165
448
 
166
449
  interface PrunerPassResult {
@@ -171,7 +454,7 @@ interface PrunerPassResult {
171
454
 
172
455
  async function runPrunerPass(
173
456
  args: LlmArgs,
174
- reflections: Reflection[],
457
+ reflections: MemoryReflection[],
175
458
  observations: ObservationRecord[],
176
459
  passContext: PrunerPassContext,
177
460
  ): Promise<PrunerPassResult> {
@@ -226,7 +509,7 @@ async function runPrunerPass(
226
509
  ${joinReflectionsOrEmpty(reflections)}
227
510
 
228
511
  CURRENT OBSERVATIONS:
229
- ${joinObservationsOrEmpty(observations)}
512
+ ${renderObservationsForPrunerPrompt(observations, passContext.coverageTags)}
230
513
 
231
514
  ${pressureLine}
232
515
 
@@ -260,7 +543,8 @@ Decide which observations to remove from the kept set. Call drop_observations wi
260
543
  };
261
544
 
262
545
  try {
263
- const stream = agentLoop(prompts, context, config, args.signal);
546
+ const loop = args.agentLoop ?? agentLoop;
547
+ const stream = loop(prompts, context, config, args.signal);
264
548
  for await (const _event of stream) {
265
549
  // Drain events; the tool's execute already records drops.
266
550
  }
@@ -275,7 +559,7 @@ Decide which observations to remove from the kept set. Call drop_observations wi
275
559
 
276
560
  export async function runPruner(
277
561
  args: LlmArgs,
278
- reflections: Reflection[],
562
+ reflections: MemoryReflection[],
279
563
  observations: ObservationRecord[],
280
564
  budgetTokens: number,
281
565
  ): Promise<PrunerResult> {
@@ -285,6 +569,7 @@ export async function runPruner(
285
569
 
286
570
  const target = Math.max(1, Math.floor(budgetTokens * PRUNER_TARGET_RATIO));
287
571
  let pool = observations;
572
+ const coverageTags = deriveObservationCoverageTags(reflections, observations);
288
573
  const allDropped: string[] = [];
289
574
  let fellBack = false;
290
575
 
@@ -299,6 +584,7 @@ export async function runPruner(
299
584
  deltaTokens,
300
585
  pass,
301
586
  maxPasses: PRUNER_MAX_PASSES,
587
+ coverageTags,
302
588
  });
303
589
 
304
590
  if (result.fellBack) {
@@ -314,13 +600,13 @@ export async function runPruner(
314
600
  return { observations: pool, droppedIds: allDropped, fellBack };
315
601
  }
316
602
 
317
- export function renderSummary(reflections: Reflection[], observations: ObservationRecord[]): string {
603
+ export function renderSummary(reflections: MemoryReflection[], observations: ObservationRecord[]): string {
318
604
  if (reflections.length === 0 && observations.length === 0) return "";
319
605
 
320
606
  const parts: string[] = [CONTEXT_USAGE_INSTRUCTIONS];
321
607
 
322
608
  if (reflections.length > 0) {
323
- parts.push(`## Reflections\n${reflections.join("\n")}`);
609
+ parts.push(`## Reflections\n${reflections.map(reflectionToPromptLine).join("\n")}`);
324
610
  }
325
611
  if (observations.length > 0) {
326
612
  const body = observationsToPromptLines(observations).join("\n");
package/src/config.ts CHANGED
@@ -6,6 +6,7 @@ export interface Config {
6
6
  observationThresholdTokens: number;
7
7
  compactionThresholdTokens: number;
8
8
  reflectionThresholdTokens: number;
9
+ passive: boolean;
9
10
  compactionModel?: { provider: string; id: string };
10
11
  }
11
12
 
@@ -13,22 +14,39 @@ export const DEFAULTS: Config = {
13
14
  observationThresholdTokens: 1_000,
14
15
  compactionThresholdTokens: 50_000,
15
16
  reflectionThresholdTokens: 30_000,
17
+ passive: false,
16
18
  };
17
19
 
18
20
  const SETTINGS_KEY = "observational-memory";
21
+ const PASSIVE_ENV = "PI_OBSERVATIONAL_MEMORY_PASSIVE";
22
+
23
+ function normalizeSettingsConfig(value: Partial<Config>): Partial<Config> {
24
+ const normalized = { ...value };
25
+ if ("passive" in normalized && typeof normalized.passive !== "boolean") delete normalized.passive;
26
+ return normalized;
27
+ }
28
+
29
+ export function readEnvConfig(env: NodeJS.ProcessEnv = process.env): Partial<Config> {
30
+ const rawPassive = env[PASSIVE_ENV];
31
+ if (rawPassive === undefined) return {};
32
+ const passive = rawPassive.trim().toLowerCase();
33
+ if (["1", "true", "yes", "on"].includes(passive)) return { passive: true };
34
+ if (["0", "false", "no", "off"].includes(passive)) return { passive: false };
35
+ return {};
36
+ }
19
37
 
20
38
  function readNamespacedConfig(path: string): Partial<Config> {
21
39
  if (!existsSync(path)) return {};
22
40
  try {
23
41
  const raw = JSON.parse(readFileSync(path, "utf-8")) as Record<string, unknown>;
24
42
  const nested = raw[SETTINGS_KEY];
25
- return nested && typeof nested === "object" ? (nested as Partial<Config>) : {};
43
+ return nested && typeof nested === "object" ? normalizeSettingsConfig(nested as Partial<Config>) : {};
26
44
  } catch {
27
45
  return {};
28
46
  }
29
47
  }
30
48
 
31
- export function loadConfig(cwd: string): Config {
49
+ export function loadConfig(cwd: string, env: NodeJS.ProcessEnv = process.env): Config {
32
50
  const globalPath = join(getAgentDir(), "settings.json");
33
51
  const projectPath = join(cwd, ".pi", "settings.json");
34
52
 
@@ -36,5 +54,6 @@ export function loadConfig(cwd: string): Config {
36
54
  ...DEFAULTS,
37
55
  ...readNamespacedConfig(globalPath),
38
56
  ...readNamespacedConfig(projectPath),
57
+ ...readEnvConfig(env),
39
58
  };
40
59
  }