pi-observational-memory 2.4.1 → 2.4.3
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +30 -2
- package/package.json +6 -5
- package/src/commands/status.ts +4 -3
- package/src/commands/view.ts +5 -3
- package/src/compaction.ts +448 -35
- package/src/config.ts +56 -0
- package/src/debug-log.ts +53 -0
- package/src/hooks/compaction-hook.ts +197 -8
- package/src/hooks/compaction-trigger.ts +24 -1
- package/src/hooks/observer-trigger.ts +70 -37
- package/src/model-budget.ts +9 -0
- package/src/observer.ts +24 -6
- package/src/progress.ts +155 -0
- package/src/prompts.ts +23 -22
package/src/config.ts
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { existsSync, readFileSync } from "node:fs";
|
|
2
2
|
import { join } from "node:path";
|
|
3
|
+
import type { ModelThinkingLevel } from "@mariozechner/pi-ai";
|
|
3
4
|
import { getAgentDir } from "@mariozechner/pi-coding-agent";
|
|
4
5
|
|
|
5
6
|
export interface Config {
|
|
@@ -7,7 +8,20 @@ export interface Config {
|
|
|
7
8
|
compactionThresholdTokens: number;
|
|
8
9
|
reflectionThresholdTokens: number;
|
|
9
10
|
passive: boolean;
|
|
11
|
+
debugLog: boolean;
|
|
10
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;
|
|
11
25
|
}
|
|
12
26
|
|
|
13
27
|
export const DEFAULTS: Config = {
|
|
@@ -15,17 +29,59 @@ export const DEFAULTS: Config = {
|
|
|
15
29
|
compactionThresholdTokens: 50_000,
|
|
16
30
|
reflectionThresholdTokens: 30_000,
|
|
17
31
|
passive: false,
|
|
32
|
+
debugLog: false,
|
|
33
|
+
thinkingLevel: "low",
|
|
18
34
|
};
|
|
19
35
|
|
|
36
|
+
export const THINKING_LEVEL_VALUES: readonly ModelThinkingLevel[] = ["off", "minimal", "low", "medium", "high", "xhigh"] as const;
|
|
37
|
+
|
|
20
38
|
const SETTINGS_KEY = "observational-memory";
|
|
21
39
|
const PASSIVE_ENV = "PI_OBSERVATIONAL_MEMORY_PASSIVE";
|
|
40
|
+
const DEFAULT_MAX_TURNS = 16;
|
|
41
|
+
|
|
42
|
+
function positiveIntegerOrUndefined(value: unknown): number | undefined {
|
|
43
|
+
return Number.isInteger(value) && typeof value === "number" && value > 0 ? value : undefined;
|
|
44
|
+
}
|
|
45
|
+
|
|
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
|
+
}
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
function isThinkingLevel(value: unknown): value is ModelThinkingLevel {
|
|
57
|
+
return typeof value === "string" && (THINKING_LEVEL_VALUES as readonly string[]).includes(value);
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
function normalizeThinkingLevel(normalized: Partial<Config>): void {
|
|
61
|
+
if (!("thinkingLevel" in normalized)) return;
|
|
62
|
+
if (!isThinkingLevel(normalized.thinkingLevel)) delete normalized.thinkingLevel;
|
|
63
|
+
}
|
|
22
64
|
|
|
23
65
|
function normalizeSettingsConfig(value: Partial<Config>): Partial<Config> {
|
|
24
66
|
const normalized = { ...value };
|
|
25
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);
|
|
26
74
|
return normalized;
|
|
27
75
|
}
|
|
28
76
|
|
|
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
|
+
};
|
|
83
|
+
}
|
|
84
|
+
|
|
29
85
|
export function readEnvConfig(env: NodeJS.ProcessEnv = process.env): Partial<Config> {
|
|
30
86
|
const rawPassive = env[PASSIVE_ENV];
|
|
31
87
|
if (rawPassive === undefined) return {};
|
package/src/debug-log.ts
ADDED
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
import { AsyncLocalStorage } from "node:async_hooks";
|
|
2
|
+
import { existsSync, mkdirSync, renameSync, statSync, unlinkSync, appendFileSync } from "node:fs";
|
|
3
|
+
import { dirname, join } from "node:path";
|
|
4
|
+
import { getAgentDir } from "@mariozechner/pi-coding-agent";
|
|
5
|
+
|
|
6
|
+
export const DEBUG_LOG_MAX_BYTES = 10 * 1024 * 1024;
|
|
7
|
+
export const DEBUG_LOG_RELATIVE_PATH = join("observational-memory", "debug.ndjson");
|
|
8
|
+
|
|
9
|
+
interface DebugLogContext {
|
|
10
|
+
enabled: boolean;
|
|
11
|
+
cwd?: string;
|
|
12
|
+
runId?: string;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
const storage = new AsyncLocalStorage<DebugLogContext>();
|
|
16
|
+
|
|
17
|
+
export function withDebugLogContext<T>(context: DebugLogContext, fn: () => T): T {
|
|
18
|
+
const parent = storage.getStore();
|
|
19
|
+
return storage.run({ ...parent, ...context }, fn);
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export function isDebugLogEnabled(): boolean {
|
|
23
|
+
return storage.getStore()?.enabled === true;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export function debugLog(event: string, data: Record<string, unknown> = {}): void {
|
|
27
|
+
const context = storage.getStore();
|
|
28
|
+
if (context?.enabled !== true) return;
|
|
29
|
+
|
|
30
|
+
try {
|
|
31
|
+
const path = join(getAgentDir(), DEBUG_LOG_RELATIVE_PATH);
|
|
32
|
+
mkdirSync(dirname(path), { recursive: true });
|
|
33
|
+
rotateIfNeeded(path);
|
|
34
|
+
const payload = {
|
|
35
|
+
ts: new Date().toISOString(),
|
|
36
|
+
event,
|
|
37
|
+
cwd: context.cwd,
|
|
38
|
+
runId: context.runId,
|
|
39
|
+
data,
|
|
40
|
+
};
|
|
41
|
+
appendFileSync(path, `${JSON.stringify(payload)}\n`, "utf-8");
|
|
42
|
+
} catch {
|
|
43
|
+
// Debug logging must never affect memory behavior.
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
function rotateIfNeeded(path: string): void {
|
|
48
|
+
if (!existsSync(path)) return;
|
|
49
|
+
if (statSync(path).size < DEBUG_LOG_MAX_BYTES) return;
|
|
50
|
+
const backupPath = `${path}.1`;
|
|
51
|
+
if (existsSync(backupPath)) unlinkSync(backupPath);
|
|
52
|
+
renameSync(path, backupPath);
|
|
53
|
+
}
|
|
@@ -1,12 +1,27 @@
|
|
|
1
1
|
import type { ExtensionAPI } from "@mariozechner/pi-coding-agent";
|
|
2
|
+
import { Text } from "@mariozechner/pi-tui";
|
|
3
|
+
import { debugLog, withDebugLogContext } from "../debug-log.js";
|
|
4
|
+
import { resolveTurnLimits } from "../config.js";
|
|
2
5
|
import {
|
|
3
6
|
collectObservationsByCoverage,
|
|
4
7
|
findLastCompactionIndex,
|
|
5
8
|
gapRawEntries,
|
|
6
9
|
getMemoryState,
|
|
7
10
|
} from "../branch.js";
|
|
8
|
-
import {
|
|
11
|
+
import {
|
|
12
|
+
REFLECTOR_MAX_PASSES,
|
|
13
|
+
coverageTagCounts,
|
|
14
|
+
migrateLegacyReflections,
|
|
15
|
+
observationPoolTokens,
|
|
16
|
+
renderSummary,
|
|
17
|
+
runPruner,
|
|
18
|
+
runReflector,
|
|
19
|
+
type CoverageTagCounts,
|
|
20
|
+
type PrunerResult,
|
|
21
|
+
type ReflectorStats,
|
|
22
|
+
} from "../compaction.js";
|
|
9
23
|
import { observationsToPromptLines, runObserver } from "../observer.js";
|
|
24
|
+
import { CompactionProgressTracker } from "../progress.js";
|
|
10
25
|
import type { Runtime } from "../runtime.js";
|
|
11
26
|
import { serializeSourceAddressedBranchEntries } from "../serialize.js";
|
|
12
27
|
import { estimateStringTokens } from "../tokens.js";
|
|
@@ -19,6 +34,23 @@ import {
|
|
|
19
34
|
type ObservationRecord,
|
|
20
35
|
} from "../types.js";
|
|
21
36
|
|
|
37
|
+
function plural(count: number, singular: string, pluralForm = `${singular}s`): string {
|
|
38
|
+
return `${count.toLocaleString()} ${count === 1 ? singular : pluralForm}`;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
function formatCoverageCounts(counts: CoverageTagCounts): string {
|
|
42
|
+
return `${counts.uncited.toLocaleString()}/${counts.cited.toLocaleString()}/${counts.reinforced.toLocaleString()} uncited/cited/reinforced`;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
function formatReflectorStats(stats: ReflectorStats): string {
|
|
46
|
+
const failed = stats.failedPass === undefined ? "" : `, failed pass ${stats.failedPass}`;
|
|
47
|
+
return `reflector ${plural(stats.toolCalls, "tool call")}, +${stats.added.toLocaleString()} added, ${stats.merged.toLocaleString()} merged, ${stats.promoted.toLocaleString()} promoted, ${stats.duplicates.toLocaleString()} duplicate/no-op, ${stats.unsupported.toLocaleString()} unsupported${failed}`;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
function formatPrunerStats(result: PrunerResult): string {
|
|
51
|
+
return `pruner dropped ${plural(result.droppedIds.length, "observation")} in ${plural(result.passes.length, "pass", "passes")}, stop: ${result.stopReason}`;
|
|
52
|
+
}
|
|
53
|
+
|
|
22
54
|
export function registerCompactionHook(pi: ExtensionAPI, runtime: Runtime): void {
|
|
23
55
|
pi.on("session_before_compact", async (event, ctx) => {
|
|
24
56
|
if (runtime.compactHookInFlight) {
|
|
@@ -29,8 +61,13 @@ export function registerCompactionHook(pi: ExtensionAPI, runtime: Runtime): void
|
|
|
29
61
|
return { cancel: true };
|
|
30
62
|
}
|
|
31
63
|
runtime.compactHookInFlight = true;
|
|
64
|
+
const progress = new CompactionProgressTracker();
|
|
65
|
+
const WIDGET_NAME = "om_compact_progress";
|
|
66
|
+
let clearWidget = () => {};
|
|
32
67
|
try {
|
|
33
68
|
runtime.ensureConfig(ctx.cwd);
|
|
69
|
+
const runId = `compaction-${Date.now().toString(36)}-${Math.random().toString(16).slice(2, 8)}`;
|
|
70
|
+
return await withDebugLogContext({ enabled: runtime.config.debugLog === true, cwd: ctx.cwd, runId }, async () => {
|
|
34
71
|
const { preparation, branchEntries, signal } = event;
|
|
35
72
|
const { firstKeptEntryId, tokensBefore } = preparation;
|
|
36
73
|
|
|
@@ -38,9 +75,19 @@ export function registerCompactionHook(pi: ExtensionAPI, runtime: Runtime): void
|
|
|
38
75
|
// the extension ctx may become stale (e.g. after session replacement/reload).
|
|
39
76
|
const hasUI = ctx.hasUI;
|
|
40
77
|
const ui = ctx.ui;
|
|
78
|
+
const turnLimits = resolveTurnLimits(runtime.config);
|
|
79
|
+
debugLog("compaction.start", {
|
|
80
|
+
firstKeptEntryId,
|
|
81
|
+
tokensBefore,
|
|
82
|
+
branchEntryCount: branchEntries.length,
|
|
83
|
+
reflectionThresholdTokens: runtime.config.reflectionThresholdTokens,
|
|
84
|
+
turnLimits,
|
|
85
|
+
legacyCompactionMaxToolCalls: runtime.config.compactionMaxToolCalls,
|
|
86
|
+
});
|
|
41
87
|
|
|
42
88
|
const resolved = await runtime.resolveModel(ctx as any);
|
|
43
89
|
if (!resolved.ok) {
|
|
90
|
+
debugLog("compaction.model_unavailable", { reason: resolved.reason });
|
|
44
91
|
if (hasUI) ui?.notify(
|
|
45
92
|
`Observational memory: cannot compact — ${resolved.reason}. ` +
|
|
46
93
|
"Fix the model/API key and try /compact manually.",
|
|
@@ -50,6 +97,23 @@ export function registerCompactionHook(pi: ExtensionAPI, runtime: Runtime): void
|
|
|
50
97
|
}
|
|
51
98
|
runtime.resolveFailureNotified = false;
|
|
52
99
|
|
|
100
|
+
const updateWidget = () => {
|
|
101
|
+
if (!hasUI || !ui) return;
|
|
102
|
+
if (!progress.getPhase()) {
|
|
103
|
+
ui.setWidget(WIDGET_NAME, undefined);
|
|
104
|
+
return;
|
|
105
|
+
}
|
|
106
|
+
ui.setWidget(WIDGET_NAME, (_tui: any, theme: any) => {
|
|
107
|
+
return new Text(
|
|
108
|
+
progress.formatWidget(theme),
|
|
109
|
+
0, 0,
|
|
110
|
+
);
|
|
111
|
+
});
|
|
112
|
+
};
|
|
113
|
+
clearWidget = () => {
|
|
114
|
+
if (hasUI && ui) ui.setWidget(WIDGET_NAME, undefined);
|
|
115
|
+
};
|
|
116
|
+
|
|
53
117
|
let entries = branchEntries as Parameters<typeof getMemoryState>[0];
|
|
54
118
|
|
|
55
119
|
if (runtime.observerPromise) {
|
|
@@ -60,6 +124,11 @@ export function registerCompactionHook(pi: ExtensionAPI, runtime: Runtime): void
|
|
|
60
124
|
}
|
|
61
125
|
|
|
62
126
|
const memoryState = getMemoryState(entries);
|
|
127
|
+
debugLog("compaction.memory_state", {
|
|
128
|
+
committedObservations: memoryState.committedObs.length,
|
|
129
|
+
pendingObservations: memoryState.pendingObs.length,
|
|
130
|
+
reflections: memoryState.reflections.length,
|
|
131
|
+
});
|
|
63
132
|
|
|
64
133
|
let gapObservationData: ObservationEntryData | null = null;
|
|
65
134
|
const gap = gapRawEntries(entries, firstKeptEntryId);
|
|
@@ -73,10 +142,19 @@ export function registerCompactionHook(pi: ExtensionAPI, runtime: Runtime): void
|
|
|
73
142
|
...memoryState.pendingObs,
|
|
74
143
|
]);
|
|
75
144
|
const gapTokenEstimate = estimateStringTokens(gapChunk);
|
|
145
|
+
debugLog("compaction.sync_catchup.start", {
|
|
146
|
+
gapEntryCount: gap.length,
|
|
147
|
+
sourceEntryIds,
|
|
148
|
+
gapFromId,
|
|
149
|
+
gapUpToId,
|
|
150
|
+
tokenEstimate: gapTokenEstimate,
|
|
151
|
+
});
|
|
76
152
|
if (hasUI) ui?.notify(
|
|
77
153
|
`Observational memory: sync catch-up observer running on ~${gapTokenEstimate.toLocaleString()}-token gap`,
|
|
78
154
|
"info",
|
|
79
155
|
);
|
|
156
|
+
progress.setPhase("observer", 1, 1);
|
|
157
|
+
updateWidget();
|
|
80
158
|
runtime.observerInFlight = true;
|
|
81
159
|
const gapCall = runObserver({
|
|
82
160
|
model: resolved.model as any,
|
|
@@ -87,6 +165,8 @@ export function registerCompactionHook(pi: ExtensionAPI, runtime: Runtime): void
|
|
|
87
165
|
chunk: gapChunk,
|
|
88
166
|
allowedSourceEntryIds: sourceEntryIds,
|
|
89
167
|
signal,
|
|
168
|
+
maxTurns: turnLimits.observerMaxTurnsPerRun,
|
|
169
|
+
thinkingLevel: runtime.config.thinkingLevel,
|
|
90
170
|
});
|
|
91
171
|
const gapPromise: Promise<void> = gapCall.then(() => undefined, () => undefined);
|
|
92
172
|
runtime.observerPromise = gapPromise;
|
|
@@ -100,12 +180,20 @@ export function registerCompactionHook(pi: ExtensionAPI, runtime: Runtime): void
|
|
|
100
180
|
coversUpToId: gapUpToId,
|
|
101
181
|
tokenCount: observationTokens,
|
|
102
182
|
};
|
|
183
|
+
debugLog("compaction.sync_catchup.records", {
|
|
184
|
+
count: records.length,
|
|
185
|
+
observationTokens,
|
|
186
|
+
coversFromId: gapFromId,
|
|
187
|
+
coversUpToId: gapUpToId,
|
|
188
|
+
records,
|
|
189
|
+
});
|
|
103
190
|
pi.appendEntry(OBSERVATION_CUSTOM_TYPE, gapObservationData);
|
|
104
191
|
if (hasUI && ui) ui.notify(
|
|
105
192
|
`Observational memory: sync catch-up recorded ${records.length} observation${records.length === 1 ? "" : "s"} (~${observationTokens.toLocaleString()} tokens)`,
|
|
106
193
|
"info",
|
|
107
194
|
);
|
|
108
195
|
} else if (hasUI && ui) {
|
|
196
|
+
debugLog("compaction.sync_catchup.empty", { gapEntryCount: gap.length });
|
|
109
197
|
ui.notify(
|
|
110
198
|
"Observational memory: sync catch-up observer returned empty — proceeding with compaction",
|
|
111
199
|
"warning",
|
|
@@ -113,6 +201,7 @@ export function registerCompactionHook(pi: ExtensionAPI, runtime: Runtime): void
|
|
|
113
201
|
}
|
|
114
202
|
} catch (error) {
|
|
115
203
|
const msg = error instanceof Error ? error.message : String(error);
|
|
204
|
+
debugLog("compaction.sync_catchup.error", { gapEntryCount: gap.length, errorMessage: msg });
|
|
116
205
|
if (hasUI && ui) ui.notify(
|
|
117
206
|
`Observational memory: sync catch-up observer failed: ${msg}. Cancelling compaction — ${gap.length} unobserved raw entries would be pruned without coverage. Try /compact again.`,
|
|
118
207
|
"warning",
|
|
@@ -129,10 +218,58 @@ export function registerCompactionHook(pi: ExtensionAPI, runtime: Runtime): void
|
|
|
129
218
|
const priorFirstKeptEntryId = priorCompactionIdx >= 0 ? entries[priorCompactionIdx].firstKeptEntryId : undefined;
|
|
130
219
|
const deltaObservationData = collectObservationsByCoverage(entries, priorFirstKeptEntryId, firstKeptEntryId);
|
|
131
220
|
if (gapObservationData) deltaObservationData.push(gapObservationData);
|
|
221
|
+
debugLog("compaction.delta", {
|
|
222
|
+
priorFirstKeptEntryId,
|
|
223
|
+
firstKeptEntryId,
|
|
224
|
+
deltaObservationEntries: deltaObservationData.length,
|
|
225
|
+
deltaObservationRecords: deltaObservationData.reduce((sum, data) => sum + data.records.length, 0),
|
|
226
|
+
gapObservationRecords: gapObservationData?.records.length ?? 0,
|
|
227
|
+
});
|
|
132
228
|
|
|
133
229
|
if (deltaObservationData.length === 0) {
|
|
134
|
-
|
|
135
|
-
|
|
230
|
+
// No new observations since last compaction. If we have existing memory,
|
|
231
|
+
// carry it forward in a no-op compaction so it survives Pi's compaction.
|
|
232
|
+
// If there is truly nothing (no prior memory either), cancel.
|
|
233
|
+
if (memoryState.committedObs.length === 0 && memoryState.reflections.length === 0) {
|
|
234
|
+
debugLog("compaction.no_delta_cancel", {
|
|
235
|
+
committedObservations: memoryState.committedObs.length,
|
|
236
|
+
pendingObservations: memoryState.pendingObs.length,
|
|
237
|
+
reflections: memoryState.reflections.length,
|
|
238
|
+
});
|
|
239
|
+
if (hasUI) {
|
|
240
|
+
ui?.notify(
|
|
241
|
+
`Observational memory: nothing to compact yet — ${plural(memoryState.committedObs.length, "committed observation")} and ${plural(memoryState.pendingObs.length, "pending observation")}; no eligible delta before compact boundary`,
|
|
242
|
+
"warning",
|
|
243
|
+
);
|
|
244
|
+
}
|
|
245
|
+
return { cancel: true };
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
// Carry forward existing memory without running reflector/pruner
|
|
249
|
+
const workingReflections: MemoryReflection[] = migrateLegacyReflections(memoryState.reflections);
|
|
250
|
+
debugLog("compaction.no_delta_carry_forward", {
|
|
251
|
+
observations: memoryState.committedObs.length,
|
|
252
|
+
reflections: workingReflections.length,
|
|
253
|
+
});
|
|
254
|
+
const summary = renderSummary(workingReflections, memoryState.committedObs);
|
|
255
|
+
const details: MemoryDetailsV4 = {
|
|
256
|
+
type: "observational-memory",
|
|
257
|
+
version: 4,
|
|
258
|
+
observations: memoryState.committedObs,
|
|
259
|
+
reflections: workingReflections,
|
|
260
|
+
};
|
|
261
|
+
if (hasUI) ui?.notify(
|
|
262
|
+
`Observational memory: no new observations — carrying forward ${memoryState.committedObs.length} observation${memoryState.committedObs.length === 1 ? "" : "s"}, ${workingReflections.length} reflection${workingReflections.length === 1 ? "" : "s"}`,
|
|
263
|
+
"info",
|
|
264
|
+
);
|
|
265
|
+
return {
|
|
266
|
+
compaction: {
|
|
267
|
+
summary,
|
|
268
|
+
firstKeptEntryId,
|
|
269
|
+
tokensBefore,
|
|
270
|
+
details,
|
|
271
|
+
},
|
|
272
|
+
};
|
|
136
273
|
}
|
|
137
274
|
|
|
138
275
|
const workingReflections: MemoryReflection[] = migrateLegacyReflections(memoryState.reflections);
|
|
@@ -141,27 +278,69 @@ export function registerCompactionHook(pi: ExtensionAPI, runtime: Runtime): void
|
|
|
141
278
|
...deltaObservationData.flatMap((d) => d.records),
|
|
142
279
|
];
|
|
143
280
|
|
|
144
|
-
const observationTokens = workingObservations
|
|
281
|
+
const observationTokens = observationPoolTokens(workingObservations);
|
|
282
|
+
debugLog("compaction.reflect_prune.gate", {
|
|
283
|
+
observationTokens,
|
|
284
|
+
reflectionThresholdTokens: runtime.config.reflectionThresholdTokens,
|
|
285
|
+
willRun: observationTokens >= runtime.config.reflectionThresholdTokens,
|
|
286
|
+
workingObservations: workingObservations.length,
|
|
287
|
+
workingReflections: workingReflections.length,
|
|
288
|
+
});
|
|
145
289
|
|
|
146
290
|
let finalReflections = workingReflections;
|
|
147
291
|
let finalObservations = workingObservations;
|
|
148
292
|
|
|
149
293
|
if (observationTokens >= runtime.config.reflectionThresholdTokens) {
|
|
150
|
-
if (hasUI) ui?.notify("Observational memory: running reflector + pruner...", "info");
|
|
151
294
|
try {
|
|
152
|
-
|
|
153
|
-
|
|
295
|
+
debugLog("compaction.reflect_prune.start", {
|
|
296
|
+
workingObservations: workingObservations.length,
|
|
297
|
+
workingReflections: workingReflections.length,
|
|
298
|
+
observationTokens,
|
|
299
|
+
});
|
|
300
|
+
if (hasUI) ui?.notify("Observational memory: running reflector + pruner...", "info");
|
|
301
|
+
progress.setPhase("reflector", 1, REFLECTOR_MAX_PASSES);
|
|
302
|
+
progress.setStartingCounts(workingReflections.length, workingObservations.length);
|
|
303
|
+
updateWidget();
|
|
304
|
+
const coverageBefore = coverageTagCounts(workingReflections, workingObservations);
|
|
305
|
+
const reflectorResult = await runReflector(
|
|
306
|
+
{ model: resolved.model as any, apiKey: resolved.apiKey, headers: resolved.headers, signal, onEvent: (event) => { progress.onEvent(event); updateWidget(); }, maxTurns: turnLimits.reflectorMaxTurnsPerPass, thinkingLevel: runtime.config.thinkingLevel },
|
|
154
307
|
workingReflections,
|
|
155
308
|
workingObservations,
|
|
309
|
+
(pass, max) => { progress.setPhase("reflector", pass, max); updateWidget(); },
|
|
156
310
|
);
|
|
311
|
+
finalReflections = reflectorResult.reflections;
|
|
312
|
+
const coverageAfter = coverageTagCounts(finalReflections, workingObservations);
|
|
313
|
+
debugLog("compaction.reflector.result", {
|
|
314
|
+
stats: reflectorResult.stats,
|
|
315
|
+
coverageBefore,
|
|
316
|
+
coverageAfter,
|
|
317
|
+
beforeReflections: workingReflections.length,
|
|
318
|
+
afterReflections: finalReflections.length,
|
|
319
|
+
});
|
|
157
320
|
|
|
158
321
|
const prunerResult = await runPruner(
|
|
159
|
-
{ model: resolved.model as any, apiKey: resolved.apiKey, headers: resolved.headers, signal },
|
|
322
|
+
{ model: resolved.model as any, apiKey: resolved.apiKey, headers: resolved.headers, signal, onEvent: (event) => { progress.onEvent(event); updateWidget(); }, maxTurns: turnLimits.prunerMaxTurnsPerPass, thinkingLevel: runtime.config.thinkingLevel },
|
|
160
323
|
finalReflections,
|
|
161
324
|
workingObservations,
|
|
162
325
|
runtime.config.reflectionThresholdTokens,
|
|
326
|
+
(pass, max) => { progress.setPhase("pruner", pass, max); updateWidget(); },
|
|
163
327
|
);
|
|
164
328
|
finalObservations = prunerResult.observations;
|
|
329
|
+
debugLog("compaction.pruner.result", {
|
|
330
|
+
stopReason: prunerResult.stopReason,
|
|
331
|
+
fellBack: prunerResult.fellBack,
|
|
332
|
+
droppedIds: prunerResult.droppedIds,
|
|
333
|
+
passes: prunerResult.passes,
|
|
334
|
+
beforeObservations: workingObservations.length,
|
|
335
|
+
afterObservations: finalObservations.length,
|
|
336
|
+
});
|
|
337
|
+
updateWidget();
|
|
338
|
+
if (hasUI) {
|
|
339
|
+
ui?.notify(
|
|
340
|
+
`Observational memory: diagnostics — ${formatReflectorStats(reflectorResult.stats)}; coverage ${formatCoverageCounts(coverageBefore)} → ${formatCoverageCounts(coverageAfter)}; ${formatPrunerStats(prunerResult)}`,
|
|
341
|
+
"info",
|
|
342
|
+
);
|
|
343
|
+
}
|
|
165
344
|
if (prunerResult.fellBack && hasUI) {
|
|
166
345
|
ui?.notify(
|
|
167
346
|
"Observational memory: pruner run failed; kept observation set unchanged",
|
|
@@ -170,6 +349,7 @@ export function registerCompactionHook(pi: ExtensionAPI, runtime: Runtime): void
|
|
|
170
349
|
}
|
|
171
350
|
} catch (error) {
|
|
172
351
|
const msg = error instanceof Error ? error.message : String(error);
|
|
352
|
+
debugLog("compaction.reflect_prune.error", { errorMessage: msg });
|
|
173
353
|
if (hasUI) ui?.notify(`Observational memory: reflect/prune failed: ${msg}`, "warning");
|
|
174
354
|
}
|
|
175
355
|
}
|
|
@@ -186,6 +366,12 @@ export function registerCompactionHook(pi: ExtensionAPI, runtime: Runtime): void
|
|
|
186
366
|
observations: finalObservations,
|
|
187
367
|
reflections: finalReflections,
|
|
188
368
|
};
|
|
369
|
+
debugLog("compaction.result", {
|
|
370
|
+
finalObservations: finalObservations.length,
|
|
371
|
+
finalReflections: finalReflections.length,
|
|
372
|
+
firstKeptEntryId,
|
|
373
|
+
tokensBefore,
|
|
374
|
+
});
|
|
189
375
|
|
|
190
376
|
if (hasUI) ui?.notify(
|
|
191
377
|
`Observational memory: compaction assembled — ${finalObservations.length} observation${finalObservations.length === 1 ? "" : "s"}, ${finalReflections.length} reflection${finalReflections.length === 1 ? "" : "s"}`,
|
|
@@ -200,8 +386,11 @@ export function registerCompactionHook(pi: ExtensionAPI, runtime: Runtime): void
|
|
|
200
386
|
details,
|
|
201
387
|
},
|
|
202
388
|
};
|
|
389
|
+
});
|
|
203
390
|
} finally {
|
|
204
391
|
runtime.compactHookInFlight = false;
|
|
392
|
+
progress.clear();
|
|
393
|
+
clearWidget();
|
|
205
394
|
}
|
|
206
395
|
});
|
|
207
396
|
}
|
|
@@ -2,12 +2,35 @@ import type { ExtensionAPI } from "@mariozechner/pi-coding-agent";
|
|
|
2
2
|
import { rawTokensSinceLastCompaction } from "../branch.js";
|
|
3
3
|
import type { Runtime } from "../runtime.js";
|
|
4
4
|
|
|
5
|
+
/**
|
|
6
|
+
* Regex matching Pi's internal retryable error detection.
|
|
7
|
+
* When the last assistant message in agent_end has stopReason "error" matching this pattern,
|
|
8
|
+
* Pi will auto-retry — we must not trigger compaction between attempts.
|
|
9
|
+
*/
|
|
10
|
+
const RETRYABLE_ERROR_RE =
|
|
11
|
+
/overloaded|provider.?returned.?error|rate.?limit|too many requests|429|500|502|503|504|service.?unavailable|server.?error|internal.?error|network.?error|connection.?error|connection.?refused|connection.?lost|websocket.?closed|websocket.?error|other side closed|fetch failed|upstream.?connect|reset before headers|socket hang up|ended without|http2 request did not get a response|timed? out|timeout|terminated|retry delay/i;
|
|
12
|
+
|
|
5
13
|
export function registerCompactionTrigger(pi: ExtensionAPI, runtime: Runtime): void {
|
|
6
|
-
pi.on("agent_end", (
|
|
14
|
+
pi.on("agent_end", (event, ctx) => {
|
|
7
15
|
runtime.ensureConfig(ctx.cwd);
|
|
8
16
|
if (runtime.config.passive === true) return;
|
|
9
17
|
if (runtime.compactInFlight) return;
|
|
10
18
|
|
|
19
|
+
// Don't trigger compaction if Pi will auto-retry — the agent hasn't truly finished.
|
|
20
|
+
// Pi emits agent_end before its own retry check, so we must detect this ourselves.
|
|
21
|
+
// The next agent_end (after retry succeeds or exhausts attempts) will re-evaluate.
|
|
22
|
+
const lastAssistant = [...event.messages].reverse().find(
|
|
23
|
+
(m): m is Extract<typeof m, { role: "assistant" }> => m.role === "assistant",
|
|
24
|
+
);
|
|
25
|
+
if (
|
|
26
|
+
lastAssistant
|
|
27
|
+
&& lastAssistant.stopReason === "error"
|
|
28
|
+
&& lastAssistant.errorMessage
|
|
29
|
+
&& RETRYABLE_ERROR_RE.test(lastAssistant.errorMessage)
|
|
30
|
+
) {
|
|
31
|
+
return;
|
|
32
|
+
}
|
|
33
|
+
|
|
11
34
|
const entries = ctx.sessionManager.getBranch() as Parameters<typeof rawTokensSinceLastCompaction>[0];
|
|
12
35
|
const tokens = rawTokensSinceLastCompaction(entries);
|
|
13
36
|
if (tokens < runtime.config.compactionThresholdTokens) return;
|
|
@@ -1,4 +1,6 @@
|
|
|
1
1
|
import type { ExtensionAPI } from "@mariozechner/pi-coding-agent";
|
|
2
|
+
import { debugLog, withDebugLogContext } from "../debug-log.js";
|
|
3
|
+
import { resolveTurnLimits } from "../config.js";
|
|
2
4
|
import {
|
|
3
5
|
firstRawIdAfter,
|
|
4
6
|
getMemoryState,
|
|
@@ -32,6 +34,7 @@ export function registerObserverTrigger(pi: ExtensionAPI, runtime: Runtime): voi
|
|
|
32
34
|
|
|
33
35
|
const { reflections, committedObs, pendingObs } = getMemoryState(entries);
|
|
34
36
|
const priorObservationLines = observationsToPromptLines([...committedObs, ...pendingObs]);
|
|
37
|
+
const turnLimits = resolveTurnLimits(runtime.config);
|
|
35
38
|
|
|
36
39
|
const chunkEntries = rawTailEntriesBetween(entries, coversFromId, coversUpToId);
|
|
37
40
|
if (chunkEntries.length === 0) return;
|
|
@@ -42,55 +45,85 @@ export function registerObserverTrigger(pi: ExtensionAPI, runtime: Runtime): voi
|
|
|
42
45
|
`Observational memory: observer running on ~${tokens.toLocaleString()}-token chunk`,
|
|
43
46
|
"info",
|
|
44
47
|
);
|
|
48
|
+
const runId = `observer-${Date.now().toString(36)}-${Math.random().toString(16).slice(2, 8)}`;
|
|
45
49
|
|
|
46
50
|
// Capture ctx properties synchronously — the async work below may outlive
|
|
47
51
|
// the extension ctx (stale after session replacement/reload).
|
|
48
52
|
const hasUI = ctx.hasUI;
|
|
49
53
|
const ui = ctx.ui;
|
|
54
|
+
const model = ctx.model;
|
|
55
|
+
const modelRegistry = ctx.modelRegistry;
|
|
56
|
+
const cwd = ctx.cwd;
|
|
50
57
|
|
|
51
|
-
void runtime.launchObserverTask(ctx, "observer", async () => {
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
58
|
+
void runtime.launchObserverTask(ctx, "observer", async () => withDebugLogContext({ enabled: runtime.config.debugLog === true, cwd, runId }, async () => {
|
|
59
|
+
try {
|
|
60
|
+
debugLog("observer.start", {
|
|
61
|
+
tokens,
|
|
62
|
+
coversFromId,
|
|
63
|
+
coversUpToId,
|
|
64
|
+
sourceEntryIds,
|
|
65
|
+
sourceEntryCount: sourceEntryIds.length,
|
|
66
|
+
priorReflections: reflections.length,
|
|
67
|
+
priorObservations: priorObservationLines.length,
|
|
68
|
+
});
|
|
69
|
+
const resolved = await runtime.resolveModel({ model, modelRegistry, hasUI, ui });
|
|
70
|
+
if (!resolved.ok) {
|
|
71
|
+
debugLog("observer.model_unavailable", { reason: resolved.reason });
|
|
72
|
+
if (!runtime.resolveFailureNotified && hasUI && ui) {
|
|
73
|
+
ui.notify(
|
|
74
|
+
`Observational memory: observer skipped — ${resolved.reason}`,
|
|
75
|
+
"warning",
|
|
76
|
+
);
|
|
77
|
+
runtime.resolveFailureNotified = true;
|
|
78
|
+
}
|
|
79
|
+
return;
|
|
80
|
+
}
|
|
81
|
+
runtime.resolveFailureNotified = false;
|
|
82
|
+
|
|
83
|
+
const records = await runObserver({
|
|
84
|
+
model: resolved.model as any,
|
|
85
|
+
apiKey: resolved.apiKey,
|
|
86
|
+
headers: resolved.headers,
|
|
87
|
+
priorReflections: reflections.map(reflectionToPromptLine),
|
|
88
|
+
priorObservations: priorObservationLines,
|
|
89
|
+
chunk,
|
|
90
|
+
allowedSourceEntryIds: sourceEntryIds,
|
|
91
|
+
maxTurns: turnLimits.observerMaxTurnsPerRun,
|
|
92
|
+
thinkingLevel: runtime.config.thinkingLevel,
|
|
93
|
+
});
|
|
94
|
+
if (!records || records.length === 0) {
|
|
95
|
+
debugLog("observer.empty", { coversFromId, coversUpToId });
|
|
96
|
+
if (hasUI && ui) ui.notify(
|
|
97
|
+
"Observational memory: observer returned no observations",
|
|
57
98
|
"warning",
|
|
58
99
|
);
|
|
59
|
-
|
|
100
|
+
return;
|
|
60
101
|
}
|
|
61
|
-
return;
|
|
62
|
-
}
|
|
63
|
-
runtime.resolveFailureNotified = false;
|
|
64
102
|
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
103
|
+
const observationTokens = records.reduce((sum, r) => sum + estimateStringTokens(r.content), 0);
|
|
104
|
+
const data: ObservationEntryData = {
|
|
105
|
+
records,
|
|
106
|
+
coversFromId,
|
|
107
|
+
coversUpToId,
|
|
108
|
+
tokenCount: observationTokens,
|
|
109
|
+
};
|
|
110
|
+
debugLog("observer.records", {
|
|
111
|
+
count: records.length,
|
|
112
|
+
observationTokens,
|
|
113
|
+
coversFromId,
|
|
114
|
+
coversUpToId,
|
|
115
|
+
records,
|
|
116
|
+
});
|
|
117
|
+
pi.appendEntry(OBSERVATION_CUSTOM_TYPE, data);
|
|
118
|
+
debugLog("observer.appended", { count: records.length, tokenCount: observationTokens, coversFromId, coversUpToId });
|
|
75
119
|
if (hasUI && ui) ui.notify(
|
|
76
|
-
|
|
77
|
-
"
|
|
120
|
+
`Observational memory: ${records.length} observation${records.length === 1 ? "" : "s"} recorded (~${observationTokens.toLocaleString()} tokens)`,
|
|
121
|
+
"info",
|
|
78
122
|
);
|
|
79
|
-
|
|
123
|
+
} catch (error) {
|
|
124
|
+
debugLog("observer.error", { errorMessage: error instanceof Error ? error.message : String(error) });
|
|
125
|
+
throw error;
|
|
80
126
|
}
|
|
81
|
-
|
|
82
|
-
const observationTokens = records.reduce((sum, r) => sum + estimateStringTokens(r.content), 0);
|
|
83
|
-
const data: ObservationEntryData = {
|
|
84
|
-
records,
|
|
85
|
-
coversFromId,
|
|
86
|
-
coversUpToId,
|
|
87
|
-
tokenCount: observationTokens,
|
|
88
|
-
};
|
|
89
|
-
pi.appendEntry(OBSERVATION_CUSTOM_TYPE, data);
|
|
90
|
-
if (hasUI && ui) ui.notify(
|
|
91
|
-
`Observational memory: ${records.length} observation${records.length === 1 ? "" : "s"} recorded (~${observationTokens.toLocaleString()} tokens)`,
|
|
92
|
-
"info",
|
|
93
|
-
);
|
|
94
|
-
});
|
|
127
|
+
}));
|
|
95
128
|
});
|
|
96
129
|
}
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
import type { Model } from "@mariozechner/pi-ai";
|
|
2
|
+
|
|
3
|
+
export const AGENT_LOOP_MAX_TOKENS = 32_000;
|
|
4
|
+
|
|
5
|
+
export function boundedMaxTokens(model: Model<any>, requested: number = AGENT_LOOP_MAX_TOKENS): number {
|
|
6
|
+
return typeof model.maxTokens === "number" && model.maxTokens > 0
|
|
7
|
+
? Math.min(model.maxTokens, requested)
|
|
8
|
+
: requested;
|
|
9
|
+
}
|