pi-observational-memory 2.4.3 → 3.0.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 (38) hide show
  1. package/README.md +318 -103
  2. package/package.json +9 -9
  3. package/src/agents/dropper/agent.ts +281 -0
  4. package/src/agents/dropper/coverage.ts +128 -0
  5. package/src/agents/dropper/pool.ts +67 -0
  6. package/src/agents/dropper/prompts.ts +48 -0
  7. package/src/{observer.ts → agents/observer/agent.ts} +11 -13
  8. package/src/agents/observer/prompts.ts +119 -0
  9. package/src/agents/reflector/agent.ts +203 -0
  10. package/src/agents/reflector/prompts.ts +81 -0
  11. package/src/clipboard.ts +63 -0
  12. package/src/commands/status.ts +79 -78
  13. package/src/commands/view.ts +58 -66
  14. package/src/config.ts +78 -55
  15. package/src/debug-log.ts +24 -5
  16. package/src/hooks/compaction-hook.ts +24 -369
  17. package/src/hooks/compaction-trigger.ts +12 -21
  18. package/src/hooks/consolidation-trigger.ts +368 -0
  19. package/src/index.ts +3 -3
  20. package/src/model-budget.ts +1 -1
  21. package/src/runtime.ts +46 -19
  22. package/src/serialize.ts +1 -1
  23. package/src/session-ledger/fold.ts +100 -0
  24. package/src/session-ledger/index.ts +6 -0
  25. package/src/session-ledger/progress.ts +129 -0
  26. package/src/session-ledger/projection.ts +220 -0
  27. package/src/session-ledger/recall.ts +237 -0
  28. package/src/session-ledger/render-summary.ts +31 -0
  29. package/src/session-ledger/types.ts +200 -0
  30. package/src/tokens.ts +1 -1
  31. package/src/tools/recall-observation.ts +84 -214
  32. package/src/branch.ts +0 -577
  33. package/src/compaction.ts +0 -1030
  34. package/src/hooks/observer-trigger.ts +0 -129
  35. package/src/progress.ts +0 -155
  36. package/src/prompts.ts +0 -302
  37. package/src/relevance.ts +0 -15
  38. package/src/types.ts +0 -155
@@ -1,104 +1,105 @@
1
- import type { ExtensionAPI } from "@mariozechner/pi-coding-agent";
2
- import { SettingsManager } from "@mariozechner/pi-coding-agent";
1
+ import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
2
+ import { observationPoolMetrics } from "../agents/dropper/pool.js";
3
+ import type { Runtime } from "../runtime.js";
3
4
  import {
4
- getMemoryState,
5
- rawTokensSinceLastBound,
5
+ diffProjection,
6
+ foldLedger,
7
+ fullProjection,
6
8
  rawTokensSinceLastCompaction,
7
- } from "../branch.js";
8
- import { observationPoolTokens as estimateObservationPoolTokens } from "../compaction.js";
9
- import { countByRelevance, formatRelevanceHistogram } from "../relevance.js";
10
- import type { Runtime } from "../runtime.js";
11
- import { estimateStringTokens } from "../tokens.js";
12
- import { reflectionContent, type MemoryReflection } from "../types.js";
9
+ rawTokensSinceObservationCoverage,
10
+ rawTokensSinceReflectionCoverage,
11
+ visibleProjection,
12
+ type Entry,
13
+ } from "../session-ledger/index.js";
13
14
 
14
- export function registerStatusCommand(pi: ExtensionAPI, runtime: Runtime): void {
15
- pi.registerCommand("om-status", {
16
- description: "Show observational memory status",
17
- handler: async (_args, ctx) => {
18
- runtime.ensureConfig(ctx.cwd);
19
- const entries = ctx.sessionManager.getBranch() as Parameters<typeof rawTokensSinceLastBound>[0];
20
- const sinceBound = rawTokensSinceLastBound(entries);
21
- const sinceCompaction = rawTokensSinceLastCompaction(entries);
15
+ function pct(current: number, total: number): number {
16
+ return total > 0 ? Math.min(100, Math.round((current / total) * 100)) : 0;
17
+ }
22
18
 
23
- const { reflections: committedRefs, committedObs, pendingObs } = getMemoryState(entries);
24
- const committedRefItems = committedRefs as MemoryReflection[];
25
- const committedObsTokens = estimateObservationPoolTokens(committedObs);
26
- const committedObsCount = committedObs.length;
27
- const committedRefsTokens = committedRefItems.reduce((s, r) => s + estimateStringTokens(reflectionContent(r)), 0);
28
- const committedRefsCount = committedRefItems.length;
19
+ function tokenSum(items: { tokenCount: number }[]): number {
20
+ return items.reduce((sum, item) => sum + item.tokenCount, 0);
21
+ }
29
22
 
30
- const pendingObsTokens = estimateObservationPoolTokens(pendingObs);
31
- const pendingObsCount = pendingObs.length;
23
+ function addedSuffix(count: number): string | undefined {
24
+ return count > 0 ? `+${count.toLocaleString()}` : undefined;
25
+ }
32
26
 
33
- const relevanceHistogram = countByRelevance([...committedObs, ...pendingObs]);
27
+ function removedSuffix(count: number): string | undefined {
28
+ return count > 0 ? `-${count.toLocaleString()}` : undefined;
29
+ }
34
30
 
35
- const keepRecentTokens = SettingsManager.create(ctx.cwd).getCompactionKeepRecentTokens();
31
+ function appendSuffixes(line: string, suffixes: (string | undefined)[]): string {
32
+ const rendered = suffixes.filter((suffix): suffix is string => suffix !== undefined);
33
+ return rendered.length > 0 ? `${line} ${rendered.join(" ")}` : line;
34
+ }
36
35
 
37
- const obsThreshold = runtime.config.observationThresholdTokens;
38
- const compThreshold = runtime.config.compactionThresholdTokens;
39
- const refThreshold = runtime.config.reflectionThresholdTokens;
40
- // Approximation: the real compaction gate excludes pending obs whose coversFromId falls inside
41
- // the new keep-recent tail (deferred to next cycle) and adds any sync-catch-up gap obs produced
42
- // at compaction entry. We over-count by the tail slice and can't predict the gap obs here.
43
- // Precise version would simulate the new firstKeptEntryId by walking back keepRecentTokens from
44
- // the branch tail and split pending into pre-tail vs tail-covering.
45
- const observationPoolTokens = estimateObservationPoolTokens([...committedObs, ...pendingObs]);
46
- const obsPct = Math.min(100, Math.round((sinceBound / obsThreshold) * 100));
47
- const compPct = Math.min(100, Math.round((sinceCompaction / compThreshold) * 100));
48
- const refPct = Math.min(100, Math.round((observationPoolTokens / refThreshold) * 100));
36
+ export function registerStatusCommand(pi: ExtensionAPI, runtime: Runtime): void {
37
+ pi.registerCommand("om-status", {
38
+ description: "Show observational memory status",
39
+ handler: async (_args, ctx) => {
40
+ runtime.ensureConfig(ctx.cwd);
41
+ const entries = ctx.sessionManager.getBranch() as Entry[];
42
+ const folded = foldLedger(entries);
43
+ const visible = visibleProjection(entries);
44
+ const full = fullProjection(entries);
45
+ const drift = diffProjection(visible, full);
49
46
 
50
- const refLabel = committedRefsCount === 1 ? "entry" : "entries";
51
- const cObsLabel = committedObsCount === 1 ? "observation" : "observations";
52
- const pObsLabel = pendingObsCount === 1 ? "observation" : "observations";
47
+ const visibleObservationTokens = tokenSum(visible.observations);
48
+ const visibleReflectionTokens = tokenSum(visible.reflections);
49
+ const activeObservationPool = observationPoolMetrics(folded.activeObservations, runtime.config.observationsPoolTargetTokens);
50
+ const observationLine = appendSuffixes(
51
+ `Observations: ${folded.observations.length} recorded / ${folded.droppedObservationIds.size} dropped / ${folded.activeObservations.length} active / ${visible.observations.length} visible`,
52
+ [
53
+ addedSuffix(drift.observationsOnlyInFull.length),
54
+ removedSuffix(drift.droppedOnlyInFull.length),
55
+ ],
56
+ );
57
+ const reflectionLine = appendSuffixes(
58
+ `Reflections: ${folded.reflections.length} recorded / ${visible.reflections.length} visible`,
59
+ [addedSuffix(drift.reflectionsOnlyInFull.length)],
60
+ );
61
+ const obsProgress = rawTokensSinceObservationCoverage(entries);
62
+ const reflectionProgress = rawTokensSinceReflectionCoverage(entries);
63
+ const compactionProgress = rawTokensSinceLastCompaction(entries);
53
64
 
54
65
  const passiveLines = runtime.config.passive === true
55
66
  ? [
56
67
  "── Mode ──",
57
- "Passive: proactive observation and compaction triggers disabled; compaction hook remains active",
68
+ "Passive: automatic memory workers and auto-compaction disabled; manual/Pi compaction, commands, and recall remain active",
58
69
  "",
59
70
  ]
60
71
  : [];
61
72
 
62
- const activityLines = runtime.config.passive === true
63
- ? [
64
- "── Activity ──",
65
- `Observation trigger: passive (~${sinceBound.toLocaleString()} / ${obsThreshold.toLocaleString()} tokens, ${obsPct}%)`,
66
- " → proactive observation is disabled; manual/Pi compaction can still run sync catch-up observation",
67
- `Compaction trigger: passive (~${sinceCompaction.toLocaleString()} / ${compThreshold.toLocaleString()} tokens, ${compPct}%)`,
68
- " → proactive extension-triggered compaction is disabled; manual/Pi compaction still uses the custom hook",
69
- `Next reflection: ~${observationPoolTokens.toLocaleString()} / ${refThreshold.toLocaleString()} tokens (${refPct}%)`,
70
- ` → if observations exceed ${refThreshold.toLocaleString()} tokens when compaction runs, reflections are`,
71
- ` distilled from them and redundant observations are pruned away`,
72
- ]
73
- : [
74
- "── Activity ──",
75
- `Next observation: ~${sinceBound.toLocaleString()} / ${obsThreshold.toLocaleString()} tokens (${obsPct}%)`,
76
- ` → at ${obsThreshold.toLocaleString()} tokens, recent conversation is compressed into new observations`,
77
- `Next compaction: ~${sinceCompaction.toLocaleString()} / ${compThreshold.toLocaleString()} tokens (${compPct}%)`,
78
- ` → at ${compThreshold.toLocaleString()} tokens, raw history is replaced by the updated reflections and`,
79
- ` observations, keeping only the last ${keepRecentTokens.toLocaleString()} tokens of conversation verbatim`,
80
- `Next reflection: ~${observationPoolTokens.toLocaleString()} / ${refThreshold.toLocaleString()} tokens (${refPct}%)`,
81
- ` → if observations exceed ${refThreshold.toLocaleString()} tokens when compaction runs, reflections are`,
82
- ` distilled from them and redundant observations are pruned away`,
83
- ];
84
-
85
73
  const lines = [
86
74
  ...passiveLines,
87
75
  "── Memory ──",
88
- `Reflections: ~${committedRefsTokens.toLocaleString()} tokens (${committedRefsCount} ${refLabel}) — durable insights`,
89
- `Observations:`,
90
- ` committed ~${committedObsTokens.toLocaleString()} tokens (${committedObsCount} ${cObsLabel}) — folded into last compaction`,
91
- ` pending ~${pendingObsTokens.toLocaleString()} tokens (${pendingObsCount} ${pObsLabel}) — waiting for next compaction`,
92
- ` relevance ${formatRelevanceHistogram(relevanceHistogram)}`,
76
+ observationLine,
77
+ reflectionLine,
93
78
  "",
94
- ...activityLines,
79
+ "── Activity ──",
80
+ `Next observation: ~${obsProgress.toLocaleString()} / ${runtime.config.observeAfterTokens.toLocaleString()} tokens (${pct(obsProgress, runtime.config.observeAfterTokens)}%)`,
81
+ `Next reflection: ~${reflectionProgress.toLocaleString()} / ${runtime.config.reflectAfterTokens.toLocaleString()} tokens (${pct(reflectionProgress, runtime.config.reflectAfterTokens)}%)`,
82
+ `Next compaction: ~${compactionProgress.toLocaleString()} / ${runtime.config.compactAfterTokens.toLocaleString()} tokens (${pct(compactionProgress, runtime.config.compactAfterTokens)}%)`,
83
+ `Visible observation pool: ~${visibleObservationTokens.toLocaleString()} / ${runtime.config.observationsPoolMaxTokens.toLocaleString()} tokens (${pct(visibleObservationTokens, runtime.config.observationsPoolMaxTokens)}%)`,
84
+ `Active observation pool: ~${activeObservationPool.observationTokens.toLocaleString()} / ${runtime.config.observationsPoolTargetTokens.toLocaleString()} target tokens (${pct(activeObservationPool.observationTokens, runtime.config.observationsPoolTargetTokens)}%)`,
85
+ `Reflection pool: ~${visibleReflectionTokens.toLocaleString()} tokens`,
95
86
  ];
96
87
 
97
- if (runtime.observerInFlight || runtime.compactInFlight) {
98
- lines.push("");
99
- lines.push("── In flight ──");
100
- if (runtime.observerInFlight) lines.push("Observer: running");
101
- if (runtime.compactInFlight) lines.push("Compaction: running");
88
+ if (runtime.consolidationInFlight || runtime.compactInFlight || runtime.compactHookInFlight) {
89
+ lines.push("", "── In flight ──");
90
+ if (runtime.consolidationInFlight) {
91
+ const phase = runtime.consolidationPhase ? ` (${runtime.consolidationPhase})` : "";
92
+ lines.push(`Consolidation: running${phase}`);
93
+ }
94
+ if (runtime.compactInFlight) lines.push("Auto-compaction: running");
95
+ if (runtime.compactHookInFlight) lines.push("Compaction hook: running");
96
+ }
97
+
98
+ if (runtime.lastObserverError || runtime.lastReflectorError || runtime.lastDropperError) {
99
+ lines.push("", "── Last error ──");
100
+ if (runtime.lastObserverError) lines.push(`Observer: ${runtime.lastObserverError}`);
101
+ if (runtime.lastReflectorError) lines.push(`Reflector: ${runtime.lastReflectorError}`);
102
+ if (runtime.lastDropperError) lines.push(`Dropper: ${runtime.lastDropperError}`);
102
103
  }
103
104
 
104
105
  ctx.ui.notify(lines.join("\n"), "info");
@@ -1,82 +1,74 @@
1
- import type { ExtensionAPI } from "@mariozechner/pi-coding-agent";
2
- import { getMemoryState } from "../branch.js";
3
- import { observationPoolTokens as estimateObservationPoolTokens } from "../compaction.js";
4
- import { countByRelevance, formatRelevanceHistogram } from "../relevance.js";
1
+ import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
5
2
  import type { Runtime } from "../runtime.js";
6
- import { estimateStringTokens } from "../tokens.js";
7
- import { reflectionContent, reflectionToPromptLine, type MemoryReflection, type ObservationRecord } from "../types.js";
3
+ import { copyTextToClipboard } from "../clipboard.js";
4
+ import {
5
+ fullProjection,
6
+ observationToSummaryLine,
7
+ reflectionToSummaryLine,
8
+ visibleProjection,
9
+ type Entry,
10
+ type Projection,
11
+ } from "../session-ledger/index.js";
8
12
 
9
- export function registerViewCommand(pi: ExtensionAPI, runtime: Runtime): void {
10
- pi.registerCommand("om-view", {
11
- description: "Print observational memory details (reflections + observations)",
12
- handler: async (_args, ctx) => {
13
- runtime.ensureConfig(ctx.cwd);
14
- const entries = ctx.sessionManager.getBranch() as Parameters<typeof getMemoryState>[0];
15
- const { reflections: committedRefs, committedObs, pendingObs } = getMemoryState(entries);
16
- const committedRefItems = committedRefs as MemoryReflection[];
17
-
18
- const committedRefTokens = committedRefItems.reduce((s, r) => s + estimateStringTokens(reflectionContent(r)), 0);
19
- const committedRefCount = committedRefItems.length;
20
-
21
- const committedObsTokens = estimateObservationPoolTokens(committedObs);
22
- const committedObsCount = committedObs.length;
13
+ function firstArg(args: unknown): string | undefined {
14
+ if (Array.isArray(args)) return typeof args[0] === "string" ? args[0] : undefined;
15
+ if (typeof args === "string") return args.trim().split(/\s+/)[0];
16
+ if (args && typeof args === "object" && "mode" in args) {
17
+ const mode = (args as { mode?: unknown }).mode;
18
+ return typeof mode === "string" ? mode : undefined;
19
+ }
20
+ return undefined;
21
+ }
23
22
 
24
- const pendingObsTokens = estimateObservationPoolTokens(pendingObs);
25
- const pendingObsCount = pendingObs.length;
23
+ function renderList<T>(items: T[], render: (item: T) => string, empty: string): string {
24
+ return items.length > 0 ? items.map(render).join("\n") : empty;
25
+ }
26
26
 
27
- const totalObsCount = committedObsCount + pendingObsCount;
28
- const totalObsTokens = estimateObservationPoolTokens([...committedObs, ...pendingObs]);
29
- const totalTokens = committedRefTokens + totalObsTokens;
30
- const relevanceHistogram = countByRelevance([...committedObs, ...pendingObs]);
27
+ function renderContentOnlyProjection(projection: Projection, emptyScope: "visible" | "recorded"): string {
28
+ return [
29
+ "── Reflections ──",
30
+ renderList(projection.reflections, reflectionToSummaryLine, `No ${emptyScope} reflections.`),
31
+ "",
32
+ "── Observations ──",
33
+ renderList(projection.observations, observationToSummaryLine, `No ${emptyScope} observations.`),
34
+ ].join("\n");
35
+ }
31
36
 
32
- const plural = (n: number, singular: string, plural: string) => (n === 1 ? singular : plural);
33
- const renderObs = (r: ObservationRecord) =>
34
- `[${r.id}] ${r.timestamp} [${r.relevance}] ${r.content}`;
37
+ interface ViewCommandOptions {
38
+ copyToClipboard?: (text: string) => Promise<boolean>;
39
+ }
35
40
 
36
- const sections: string[] = [];
41
+ export function registerViewCommand(pi: ExtensionAPI, runtime: Runtime, options: ViewCommandOptions = {}): void {
42
+ const copyToClipboard = options.copyToClipboard ?? copyTextToClipboard;
37
43
 
38
- sections.push(
39
- `Memory: ${committedRefCount} ${plural(committedRefCount, "reflection", "reflections")} · ` +
40
- `${totalObsCount} ${plural(totalObsCount, "observation", "observations")} ` +
41
- `(${committedObsCount} committed, ${pendingObsCount} pending) · ` +
42
- `~${totalTokens.toLocaleString()} tokens · ` +
43
- `relevance ${formatRelevanceHistogram(relevanceHistogram)}`,
44
- );
45
- sections.push("");
44
+ pi.registerCommand("om-view", {
45
+ description: "Print and copy observational memory content (visible by default, full for recorded memory)",
46
+ handler: async (args, ctx) => {
47
+ runtime.ensureConfig(ctx.cwd);
48
+ const entries = ctx.sessionManager.getBranch() as Entry[];
49
+ const mode = firstArg(args);
46
50
 
47
- sections.push(
48
- `── Reflections (${committedRefCount} ${plural(committedRefCount, "entry", "entries")}, ~${committedRefTokens.toLocaleString()} tokens) ──`,
49
- );
50
- if (committedRefItems.length > 0) {
51
- sections.push(committedRefItems.map(reflectionToPromptLine).join("\n\n"));
52
- } else {
53
- sections.push("(none)");
54
- }
51
+ const notifyWithCopy = async (output: string) => {
52
+ const copied = await copyToClipboard(output).catch(() => false);
53
+ ctx.ui.notify(
54
+ copied
55
+ ? `${output}\n\nCopied /om-view output to clipboard.`
56
+ : `${output}\n\nWarning: failed to copy /om-view output to clipboard.`,
57
+ "info",
58
+ );
59
+ };
55
60
 
56
- sections.push("");
57
- sections.push(
58
- `── Observations — committed (${committedObsCount} ${plural(committedObsCount, "observation", "observations")}, ~${committedObsTokens.toLocaleString()} tokens) ──`,
59
- );
60
- if (committedObs.length > 0) {
61
- sections.push(committedObs.map(renderObs).join("\n"));
62
- } else {
63
- sections.push("(none)");
61
+ if (mode === "full") {
62
+ await notifyWithCopy(renderContentOnlyProjection(fullProjection(entries), "recorded"));
63
+ return;
64
64
  }
65
65
 
66
- sections.push("");
67
- sections.push(
68
- `── Observations — pending (${pendingObsCount} ${plural(pendingObsCount, "observation", "observations")}, ~${pendingObsTokens.toLocaleString()} tokens) ──`,
69
- );
70
- if (pendingObs.length > 0) {
71
- sections.push(pendingObs.map(renderObs).join("\n"));
72
- } else {
73
- sections.push("(none)");
66
+ if (mode && mode !== "visible") {
67
+ ctx.ui.notify("Usage: /om-view [full]", "info");
68
+ return;
74
69
  }
75
70
 
76
- sections.push("");
77
- sections.push("Tip: use /tree to browse the raw messages still live in the session.");
78
-
79
- ctx.ui.notify(sections.join("\n"), "info");
71
+ await notifyWithCopy(renderContentOnlyProjection(visibleProjection(entries), "visible"));
80
72
  },
81
73
  });
82
74
  }
package/src/config.ts CHANGED
@@ -1,85 +1,96 @@
1
1
  import { existsSync, readFileSync } from "node:fs";
2
2
  import { join } from "node:path";
3
- import type { ModelThinkingLevel } from "@mariozechner/pi-ai";
4
- import { getAgentDir } from "@mariozechner/pi-coding-agent";
3
+ import type { ModelThinkingLevel } from "@earendil-works/pi-ai";
4
+ import { getAgentDir } from "@earendil-works/pi-coding-agent";
5
+
6
+ export interface ConfiguredModel {
7
+ provider: string;
8
+ id: string;
9
+ thinking?: ModelThinkingLevel;
10
+ }
5
11
 
6
12
  export interface Config {
7
- observationThresholdTokens: number;
8
- compactionThresholdTokens: number;
9
- reflectionThresholdTokens: number;
13
+ observeAfterTokens: number;
14
+ reflectAfterTokens: number;
15
+ compactAfterTokens: number;
16
+ observationsPoolMaxTokens: number;
17
+ observationsPoolTargetTokens: number;
18
+ agentMaxTurns: number;
19
+ model?: ConfiguredModel;
10
20
  passive: boolean;
11
21
  debugLog: boolean;
12
- compactionModel?: { provider: string; id: string };
13
- observerMaxTurnsPerRun?: number;
14
- reflectorMaxTurnsPerPass?: number;
15
- prunerMaxTurnsPerPass?: number;
16
- thinkingLevel: ModelThinkingLevel;
17
- /** @deprecated Use reflectorMaxTurnsPerPass and prunerMaxTurnsPerPass. */
18
- compactionMaxToolCalls?: number;
19
- }
20
-
21
- export interface EffectiveTurnLimits {
22
- observerMaxTurnsPerRun: number;
23
- reflectorMaxTurnsPerPass: number;
24
- prunerMaxTurnsPerPass: number;
25
22
  }
26
23
 
27
24
  export const DEFAULTS: Config = {
28
- observationThresholdTokens: 1_000,
29
- compactionThresholdTokens: 50_000,
30
- reflectionThresholdTokens: 30_000,
25
+ observeAfterTokens: 10_000,
26
+ reflectAfterTokens: 20_000,
27
+ compactAfterTokens: 81_000,
28
+ observationsPoolMaxTokens: 20_000,
29
+ observationsPoolTargetTokens: 10_000,
30
+ agentMaxTurns: 16,
31
31
  passive: false,
32
32
  debugLog: false,
33
- thinkingLevel: "low",
34
33
  };
35
34
 
36
35
  export const THINKING_LEVEL_VALUES: readonly ModelThinkingLevel[] = ["off", "minimal", "low", "medium", "high", "xhigh"] as const;
37
36
 
38
37
  const SETTINGS_KEY = "observational-memory";
39
38
  const PASSIVE_ENV = "PI_OBSERVATIONAL_MEMORY_PASSIVE";
40
- const DEFAULT_MAX_TURNS = 16;
41
39
 
42
40
  function positiveIntegerOrUndefined(value: unknown): number | undefined {
43
41
  return Number.isInteger(value) && typeof value === "number" && value > 0 ? value : undefined;
44
42
  }
45
43
 
46
- function normalizeTurnLimit<K extends keyof Config>(normalized: Partial<Config>, key: K): void {
47
- if (!(key in normalized)) return;
48
- const value = positiveIntegerOrUndefined(normalized[key]);
49
- if (value === undefined) {
50
- delete normalized[key];
51
- } else {
52
- (normalized as Record<K, number>)[key] = value;
53
- }
44
+ function validTargetOrUndefined(value: unknown, maxTokens: number): number | undefined {
45
+ const target = positiveIntegerOrUndefined(value);
46
+ return target !== undefined && target < maxTokens ? target : undefined;
47
+ }
48
+
49
+ function derivedObservationPoolTarget(maxTokens: number): number {
50
+ return Math.floor(maxTokens / 2);
54
51
  }
55
52
 
56
53
  function isThinkingLevel(value: unknown): value is ModelThinkingLevel {
57
54
  return typeof value === "string" && (THINKING_LEVEL_VALUES as readonly string[]).includes(value);
58
55
  }
59
56
 
60
- function normalizeThinkingLevel(normalized: Partial<Config>): void {
61
- if (!("thinkingLevel" in normalized)) return;
62
- if (!isThinkingLevel(normalized.thinkingLevel)) delete normalized.thinkingLevel;
57
+ function isRecord(value: unknown): value is Record<string, unknown> {
58
+ return typeof value === "object" && value !== null;
63
59
  }
64
60
 
65
- function normalizeSettingsConfig(value: Partial<Config>): Partial<Config> {
66
- const normalized = { ...value };
67
- if ("passive" in normalized && typeof normalized.passive !== "boolean") delete normalized.passive;
68
- if ("debugLog" in normalized && typeof normalized.debugLog !== "boolean") delete normalized.debugLog;
69
- normalizeTurnLimit(normalized, "observerMaxTurnsPerRun");
70
- normalizeTurnLimit(normalized, "reflectorMaxTurnsPerPass");
71
- normalizeTurnLimit(normalized, "prunerMaxTurnsPerPass");
72
- normalizeTurnLimit(normalized, "compactionMaxToolCalls");
73
- normalizeThinkingLevel(normalized);
74
- return normalized;
61
+ function nonEmptyString(value: unknown): string | undefined {
62
+ return typeof value === "string" && value.length > 0 ? value : undefined;
75
63
  }
76
64
 
77
- export function resolveTurnLimits(config: Config): EffectiveTurnLimits {
78
- return {
79
- observerMaxTurnsPerRun: config.observerMaxTurnsPerRun ?? DEFAULT_MAX_TURNS,
80
- reflectorMaxTurnsPerPass: config.reflectorMaxTurnsPerPass ?? config.compactionMaxToolCalls ?? DEFAULT_MAX_TURNS,
81
- prunerMaxTurnsPerPass: config.prunerMaxTurnsPerPass ?? config.compactionMaxToolCalls ?? DEFAULT_MAX_TURNS,
82
- };
65
+ function normalizeModel(value: unknown): ConfiguredModel | undefined {
66
+ if (!isRecord(value)) return undefined;
67
+ const provider = nonEmptyString(value.provider);
68
+ const id = nonEmptyString(value.id);
69
+ if (!provider || !id) return undefined;
70
+ const model: ConfiguredModel = { provider, id };
71
+ if (isThinkingLevel(value.thinking)) model.thinking = value.thinking;
72
+ return model;
73
+ }
74
+
75
+ function normalizeSettingsConfig(value: Record<string, unknown>): Partial<Config> {
76
+ const normalized: Partial<Config> = {};
77
+ const numberKeys = [
78
+ "observeAfterTokens",
79
+ "reflectAfterTokens",
80
+ "compactAfterTokens",
81
+ "observationsPoolMaxTokens",
82
+ "observationsPoolTargetTokens",
83
+ "agentMaxTurns",
84
+ ] as const;
85
+ for (const key of numberKeys) {
86
+ const normalizedValue = positiveIntegerOrUndefined(value[key]);
87
+ if (normalizedValue !== undefined) normalized[key] = normalizedValue;
88
+ }
89
+ if (typeof value.passive === "boolean") normalized.passive = value.passive;
90
+ if (typeof value.debugLog === "boolean") normalized.debugLog = value.debugLog;
91
+ const model = normalizeModel(value.model);
92
+ if (model) normalized.model = model;
93
+ return normalized;
83
94
  }
84
95
 
85
96
  export function readEnvConfig(env: NodeJS.ProcessEnv = process.env): Partial<Config> {
@@ -96,7 +107,7 @@ function readNamespacedConfig(path: string): Partial<Config> {
96
107
  try {
97
108
  const raw = JSON.parse(readFileSync(path, "utf-8")) as Record<string, unknown>;
98
109
  const nested = raw[SETTINGS_KEY];
99
- return nested && typeof nested === "object" ? normalizeSettingsConfig(nested as Partial<Config>) : {};
110
+ return isRecord(nested) ? normalizeSettingsConfig(nested) : {};
100
111
  } catch {
101
112
  return {};
102
113
  }
@@ -105,11 +116,23 @@ function readNamespacedConfig(path: string): Partial<Config> {
105
116
  export function loadConfig(cwd: string, env: NodeJS.ProcessEnv = process.env): Config {
106
117
  const globalPath = join(getAgentDir(), "settings.json");
107
118
  const projectPath = join(cwd, ".pi", "settings.json");
119
+ const globalConfig = readNamespacedConfig(globalPath);
120
+ const projectConfig = readNamespacedConfig(projectPath);
121
+ const envConfig = readEnvConfig(env);
122
+ const merged = {
123
+ ...DEFAULTS,
124
+ observationsPoolTargetTokens: undefined,
125
+ ...globalConfig,
126
+ ...projectConfig,
127
+ ...envConfig,
128
+ };
129
+ const target = validTargetOrUndefined(
130
+ merged.observationsPoolTargetTokens,
131
+ merged.observationsPoolMaxTokens,
132
+ ) ?? derivedObservationPoolTarget(merged.observationsPoolMaxTokens);
108
133
 
109
134
  return {
110
- ...DEFAULTS,
111
- ...readNamespacedConfig(globalPath),
112
- ...readNamespacedConfig(projectPath),
113
- ...readEnvConfig(env),
135
+ ...merged,
136
+ observationsPoolTargetTokens: target,
114
137
  };
115
138
  }
package/src/debug-log.ts CHANGED
@@ -1,14 +1,17 @@
1
1
  import { AsyncLocalStorage } from "node:async_hooks";
2
2
  import { existsSync, mkdirSync, renameSync, statSync, unlinkSync, appendFileSync } from "node:fs";
3
3
  import { dirname, join } from "node:path";
4
- import { getAgentDir } from "@mariozechner/pi-coding-agent";
4
+ import { getAgentDir } from "@earendil-works/pi-coding-agent";
5
5
 
6
6
  export const DEBUG_LOG_MAX_BYTES = 10 * 1024 * 1024;
7
7
  export const DEBUG_LOG_RELATIVE_PATH = join("observational-memory", "debug.ndjson");
8
+ export const DEBUG_LOG_SESSION_DIR_RELATIVE_PATH = join("observational-memory", "debug");
8
9
 
9
- interface DebugLogContext {
10
+ export interface DebugLogContext {
10
11
  enabled: boolean;
11
12
  cwd?: string;
13
+ sessionId?: string;
14
+ sessionFile?: string;
12
15
  runId?: string;
13
16
  }
14
17
 
@@ -19,8 +22,22 @@ export function withDebugLogContext<T>(context: DebugLogContext, fn: () => T): T
19
22
  return storage.run({ ...parent, ...context }, fn);
20
23
  }
21
24
 
22
- export function isDebugLogEnabled(): boolean {
23
- return storage.getStore()?.enabled === true;
25
+ export function safeDebugLogSessionId(sessionId: string | undefined): string | undefined {
26
+ const trimmed = sessionId?.trim();
27
+ if (!trimmed) return undefined;
28
+ const sanitized = trimmed
29
+ .replace(/[^A-Za-z0-9._-]+/g, "_")
30
+ .replace(/^_+|_+$/g, "")
31
+ .slice(0, 128);
32
+ if (!/[A-Za-z0-9]/.test(sanitized)) return undefined;
33
+ return sanitized;
34
+ }
35
+
36
+ export function debugLogRelativePath(context: Pick<DebugLogContext, "sessionId">): string {
37
+ const safeSessionId = safeDebugLogSessionId(context.sessionId);
38
+ return safeSessionId
39
+ ? join(DEBUG_LOG_SESSION_DIR_RELATIVE_PATH, `${safeSessionId}.ndjson`)
40
+ : DEBUG_LOG_RELATIVE_PATH;
24
41
  }
25
42
 
26
43
  export function debugLog(event: string, data: Record<string, unknown> = {}): void {
@@ -28,13 +45,15 @@ export function debugLog(event: string, data: Record<string, unknown> = {}): voi
28
45
  if (context?.enabled !== true) return;
29
46
 
30
47
  try {
31
- const path = join(getAgentDir(), DEBUG_LOG_RELATIVE_PATH);
48
+ const path = join(getAgentDir(), debugLogRelativePath(context));
32
49
  mkdirSync(dirname(path), { recursive: true });
33
50
  rotateIfNeeded(path);
34
51
  const payload = {
35
52
  ts: new Date().toISOString(),
36
53
  event,
37
54
  cwd: context.cwd,
55
+ sessionId: context.sessionId,
56
+ sessionFile: context.sessionFile,
38
57
  runId: context.runId,
39
58
  data,
40
59
  };