@aexol/spectral 0.3.6 → 0.3.8
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/dist/cli.js +12 -1
- package/dist/memory/branch.js +409 -0
- package/dist/memory/commands/status.js +91 -0
- package/dist/memory/commands/view.js +61 -0
- package/dist/memory/compaction.js +813 -0
- package/dist/memory/config.js +79 -0
- package/dist/memory/debug-log.js +45 -0
- package/dist/memory/hooks/compaction-hook.js +319 -0
- package/dist/memory/hooks/compaction-trigger.js +87 -0
- package/dist/memory/hooks/observer-trigger.js +108 -0
- package/dist/memory/ids.js +4 -0
- package/dist/memory/index.js +16 -0
- package/dist/memory/model-budget.js +6 -0
- package/dist/memory/observer.js +157 -0
- package/dist/memory/progress.js +134 -0
- package/dist/memory/prompts.js +287 -0
- package/dist/memory/relevance.js +14 -0
- package/dist/memory/runtime.js +62 -0
- package/dist/memory/serialize.js +199 -0
- package/dist/memory/tokens.js +26 -0
- package/dist/memory/tools/recall-observation.js +508 -0
- package/dist/memory/types.js +95 -0
- package/dist/relay/dispatcher.js +2 -0
- package/dist/server/session-stream.js +1 -0
- package/dist/server/storage.js +7 -1
- package/package.json +2 -1
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
import { existsSync, readFileSync } from "node:fs";
|
|
2
|
+
import { join } from "node:path";
|
|
3
|
+
import { getAgentDir } from "@mariozechner/pi-coding-agent";
|
|
4
|
+
export const DEFAULTS = {
|
|
5
|
+
observationThresholdTokens: 1_000,
|
|
6
|
+
compactionThresholdTokens: 50_000,
|
|
7
|
+
reflectionThresholdTokens: 30_000,
|
|
8
|
+
passive: false,
|
|
9
|
+
debugLog: false,
|
|
10
|
+
};
|
|
11
|
+
const SETTINGS_KEY = "observational-memory";
|
|
12
|
+
const PASSIVE_ENV = "PI_OBSERVATIONAL_MEMORY_PASSIVE";
|
|
13
|
+
const DEFAULT_MAX_TURNS = 16;
|
|
14
|
+
function positiveIntegerOrUndefined(value) {
|
|
15
|
+
return Number.isInteger(value) && typeof value === "number" && value > 0 ? value : undefined;
|
|
16
|
+
}
|
|
17
|
+
function normalizeTurnLimit(normalized, key) {
|
|
18
|
+
if (!(key in normalized))
|
|
19
|
+
return;
|
|
20
|
+
const value = positiveIntegerOrUndefined(normalized[key]);
|
|
21
|
+
if (value === undefined) {
|
|
22
|
+
delete normalized[key];
|
|
23
|
+
}
|
|
24
|
+
else {
|
|
25
|
+
normalized[key] = value;
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
function normalizeSettingsConfig(value) {
|
|
29
|
+
const normalized = { ...value };
|
|
30
|
+
if ("passive" in normalized && typeof normalized.passive !== "boolean")
|
|
31
|
+
delete normalized.passive;
|
|
32
|
+
if ("debugLog" in normalized && typeof normalized.debugLog !== "boolean")
|
|
33
|
+
delete normalized.debugLog;
|
|
34
|
+
normalizeTurnLimit(normalized, "observerMaxTurnsPerRun");
|
|
35
|
+
normalizeTurnLimit(normalized, "reflectorMaxTurnsPerPass");
|
|
36
|
+
normalizeTurnLimit(normalized, "prunerMaxTurnsPerPass");
|
|
37
|
+
normalizeTurnLimit(normalized, "compactionMaxToolCalls");
|
|
38
|
+
return normalized;
|
|
39
|
+
}
|
|
40
|
+
export function resolveTurnLimits(config) {
|
|
41
|
+
return {
|
|
42
|
+
observerMaxTurnsPerRun: config.observerMaxTurnsPerRun ?? DEFAULT_MAX_TURNS,
|
|
43
|
+
reflectorMaxTurnsPerPass: config.reflectorMaxTurnsPerPass ?? config.compactionMaxToolCalls ?? DEFAULT_MAX_TURNS,
|
|
44
|
+
prunerMaxTurnsPerPass: config.prunerMaxTurnsPerPass ?? config.compactionMaxToolCalls ?? DEFAULT_MAX_TURNS,
|
|
45
|
+
};
|
|
46
|
+
}
|
|
47
|
+
export function readEnvConfig(env = process.env) {
|
|
48
|
+
const rawPassive = env[PASSIVE_ENV];
|
|
49
|
+
if (rawPassive === undefined)
|
|
50
|
+
return {};
|
|
51
|
+
const passive = rawPassive.trim().toLowerCase();
|
|
52
|
+
if (["1", "true", "yes", "on"].includes(passive))
|
|
53
|
+
return { passive: true };
|
|
54
|
+
if (["0", "false", "no", "off"].includes(passive))
|
|
55
|
+
return { passive: false };
|
|
56
|
+
return {};
|
|
57
|
+
}
|
|
58
|
+
function readNamespacedConfig(path) {
|
|
59
|
+
if (!existsSync(path))
|
|
60
|
+
return {};
|
|
61
|
+
try {
|
|
62
|
+
const raw = JSON.parse(readFileSync(path, "utf-8"));
|
|
63
|
+
const nested = raw[SETTINGS_KEY];
|
|
64
|
+
return nested && typeof nested === "object" ? normalizeSettingsConfig(nested) : {};
|
|
65
|
+
}
|
|
66
|
+
catch {
|
|
67
|
+
return {};
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
export function loadConfig(cwd, env = process.env) {
|
|
71
|
+
const globalPath = join(getAgentDir(), "settings.json");
|
|
72
|
+
const projectPath = join(cwd, ".pi", "settings.json");
|
|
73
|
+
return {
|
|
74
|
+
...DEFAULTS,
|
|
75
|
+
...readNamespacedConfig(globalPath),
|
|
76
|
+
...readNamespacedConfig(projectPath),
|
|
77
|
+
...readEnvConfig(env),
|
|
78
|
+
};
|
|
79
|
+
}
|
|
@@ -0,0 +1,45 @@
|
|
|
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
|
+
export const DEBUG_LOG_MAX_BYTES = 10 * 1024 * 1024;
|
|
6
|
+
export const DEBUG_LOG_RELATIVE_PATH = join("observational-memory", "debug.ndjson");
|
|
7
|
+
const storage = new AsyncLocalStorage();
|
|
8
|
+
export function withDebugLogContext(context, fn) {
|
|
9
|
+
const parent = storage.getStore();
|
|
10
|
+
return storage.run({ ...parent, ...context }, fn);
|
|
11
|
+
}
|
|
12
|
+
export function isDebugLogEnabled() {
|
|
13
|
+
return storage.getStore()?.enabled === true;
|
|
14
|
+
}
|
|
15
|
+
export function debugLog(event, data = {}) {
|
|
16
|
+
const context = storage.getStore();
|
|
17
|
+
if (context?.enabled !== true)
|
|
18
|
+
return;
|
|
19
|
+
try {
|
|
20
|
+
const path = join(getAgentDir(), DEBUG_LOG_RELATIVE_PATH);
|
|
21
|
+
mkdirSync(dirname(path), { recursive: true });
|
|
22
|
+
rotateIfNeeded(path);
|
|
23
|
+
const payload = {
|
|
24
|
+
ts: new Date().toISOString(),
|
|
25
|
+
event,
|
|
26
|
+
cwd: context.cwd,
|
|
27
|
+
runId: context.runId,
|
|
28
|
+
data,
|
|
29
|
+
};
|
|
30
|
+
appendFileSync(path, `${JSON.stringify(payload)}\n`, "utf-8");
|
|
31
|
+
}
|
|
32
|
+
catch {
|
|
33
|
+
// Debug logging must never affect memory behavior.
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
function rotateIfNeeded(path) {
|
|
37
|
+
if (!existsSync(path))
|
|
38
|
+
return;
|
|
39
|
+
if (statSync(path).size < DEBUG_LOG_MAX_BYTES)
|
|
40
|
+
return;
|
|
41
|
+
const backupPath = `${path}.1`;
|
|
42
|
+
if (existsSync(backupPath))
|
|
43
|
+
unlinkSync(backupPath);
|
|
44
|
+
renameSync(path, backupPath);
|
|
45
|
+
}
|
|
@@ -0,0 +1,319 @@
|
|
|
1
|
+
import { Text } from "@mariozechner/pi-tui";
|
|
2
|
+
import { debugLog, withDebugLogContext } from "../debug-log.js";
|
|
3
|
+
import { resolveTurnLimits } from "../config.js";
|
|
4
|
+
import { collectObservationsByCoverage, findLastCompactionIndex, gapRawEntries, getMemoryState, } from "../branch.js";
|
|
5
|
+
import { REFLECTOR_MAX_PASSES, coverageTagCounts, migrateLegacyReflections, observationPoolTokens, renderSummary, runPruner, runReflector, } from "../compaction.js";
|
|
6
|
+
import { observationsToPromptLines, runObserver } from "../observer.js";
|
|
7
|
+
import { CompactionProgressTracker } from "../progress.js";
|
|
8
|
+
import { serializeSourceAddressedBranchEntries } from "../serialize.js";
|
|
9
|
+
import { estimateStringTokens } from "../tokens.js";
|
|
10
|
+
import { OBSERVATION_CUSTOM_TYPE, reflectionToPromptLine, } from "../types.js";
|
|
11
|
+
function plural(count, singular, pluralForm = `${singular}s`) {
|
|
12
|
+
return `${count.toLocaleString()} ${count === 1 ? singular : pluralForm}`;
|
|
13
|
+
}
|
|
14
|
+
function formatCoverageCounts(counts) {
|
|
15
|
+
return `${counts.uncited.toLocaleString()}/${counts.cited.toLocaleString()}/${counts.reinforced.toLocaleString()} uncited/cited/reinforced`;
|
|
16
|
+
}
|
|
17
|
+
function formatReflectorStats(stats) {
|
|
18
|
+
const failed = stats.failedPass === undefined ? "" : `, failed pass ${stats.failedPass}`;
|
|
19
|
+
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}`;
|
|
20
|
+
}
|
|
21
|
+
function formatPrunerStats(result) {
|
|
22
|
+
return `pruner dropped ${plural(result.droppedIds.length, "observation")} in ${plural(result.passes.length, "pass", "passes")}, stop: ${result.stopReason}`;
|
|
23
|
+
}
|
|
24
|
+
export function registerCompactionHook(pi, runtime) {
|
|
25
|
+
pi.on("session_before_compact", async (event, ctx) => {
|
|
26
|
+
if (runtime.compactHookInFlight) {
|
|
27
|
+
if (ctx.hasUI)
|
|
28
|
+
ctx.ui.notify("Observational memory: another compaction is already in progress; cancelling duplicate", "warning");
|
|
29
|
+
return { cancel: true };
|
|
30
|
+
}
|
|
31
|
+
runtime.compactHookInFlight = true;
|
|
32
|
+
const progress = new CompactionProgressTracker();
|
|
33
|
+
const WIDGET_NAME = "om_compact_progress";
|
|
34
|
+
let clearWidget = () => { };
|
|
35
|
+
try {
|
|
36
|
+
runtime.ensureConfig(ctx.cwd);
|
|
37
|
+
const runId = `compaction-${Date.now().toString(36)}-${Math.random().toString(16).slice(2, 8)}`;
|
|
38
|
+
return await withDebugLogContext({ enabled: runtime.config.debugLog === true, cwd: ctx.cwd, runId }, async () => {
|
|
39
|
+
const { preparation, branchEntries, signal } = event;
|
|
40
|
+
const { firstKeptEntryId, tokensBefore } = preparation;
|
|
41
|
+
// Capture ctx properties synchronously — after multiple awaits below,
|
|
42
|
+
// the extension ctx may become stale (e.g. after session replacement/reload).
|
|
43
|
+
const hasUI = ctx.hasUI;
|
|
44
|
+
const ui = ctx.ui;
|
|
45
|
+
const turnLimits = resolveTurnLimits(runtime.config);
|
|
46
|
+
debugLog("compaction.start", {
|
|
47
|
+
firstKeptEntryId,
|
|
48
|
+
tokensBefore,
|
|
49
|
+
branchEntryCount: branchEntries.length,
|
|
50
|
+
reflectionThresholdTokens: runtime.config.reflectionThresholdTokens,
|
|
51
|
+
turnLimits,
|
|
52
|
+
legacyCompactionMaxToolCalls: runtime.config.compactionMaxToolCalls,
|
|
53
|
+
});
|
|
54
|
+
const resolved = await runtime.resolveModel(ctx);
|
|
55
|
+
if (!resolved.ok) {
|
|
56
|
+
debugLog("compaction.model_unavailable", { reason: resolved.reason });
|
|
57
|
+
if (hasUI)
|
|
58
|
+
ui?.notify(`Observational memory: cannot compact — ${resolved.reason}. ` +
|
|
59
|
+
"Fix the model/API key and try /compact manually.", "error");
|
|
60
|
+
return { cancel: true };
|
|
61
|
+
}
|
|
62
|
+
runtime.resolveFailureNotified = false;
|
|
63
|
+
const updateWidget = () => {
|
|
64
|
+
if (!hasUI || !ui)
|
|
65
|
+
return;
|
|
66
|
+
if (!progress.getPhase()) {
|
|
67
|
+
ui.setWidget(WIDGET_NAME, undefined);
|
|
68
|
+
return;
|
|
69
|
+
}
|
|
70
|
+
ui.setWidget(WIDGET_NAME, (_tui, theme) => {
|
|
71
|
+
return new Text(progress.formatWidget(theme), 0, 0);
|
|
72
|
+
});
|
|
73
|
+
};
|
|
74
|
+
clearWidget = () => {
|
|
75
|
+
if (hasUI && ui)
|
|
76
|
+
ui.setWidget(WIDGET_NAME, undefined);
|
|
77
|
+
};
|
|
78
|
+
let entries = branchEntries;
|
|
79
|
+
if (runtime.observerPromise) {
|
|
80
|
+
try {
|
|
81
|
+
await runtime.observerPromise;
|
|
82
|
+
}
|
|
83
|
+
catch { /* already notified via launchObserverTask */ }
|
|
84
|
+
// In-flight observer may have appended a new observation entry during the await;
|
|
85
|
+
// refresh from sessionManager so gap computation and coverage collection see it
|
|
86
|
+
entries = ctx.sessionManager.getBranch();
|
|
87
|
+
}
|
|
88
|
+
const memoryState = getMemoryState(entries);
|
|
89
|
+
debugLog("compaction.memory_state", {
|
|
90
|
+
committedObservations: memoryState.committedObs.length,
|
|
91
|
+
pendingObservations: memoryState.pendingObs.length,
|
|
92
|
+
reflections: memoryState.reflections.length,
|
|
93
|
+
});
|
|
94
|
+
let gapObservationData = null;
|
|
95
|
+
const gap = gapRawEntries(entries, firstKeptEntryId);
|
|
96
|
+
if (gap.length > 0) {
|
|
97
|
+
const { text: gapChunk, sourceEntryIds } = serializeSourceAddressedBranchEntries(gap);
|
|
98
|
+
if (gapChunk.trim() && sourceEntryIds.length > 0) {
|
|
99
|
+
const gapFromId = gap[0].id;
|
|
100
|
+
const gapUpToId = gap[gap.length - 1].id;
|
|
101
|
+
const priorObservationLines = observationsToPromptLines([
|
|
102
|
+
...memoryState.committedObs,
|
|
103
|
+
...memoryState.pendingObs,
|
|
104
|
+
]);
|
|
105
|
+
const gapTokenEstimate = estimateStringTokens(gapChunk);
|
|
106
|
+
debugLog("compaction.sync_catchup.start", {
|
|
107
|
+
gapEntryCount: gap.length,
|
|
108
|
+
sourceEntryIds,
|
|
109
|
+
gapFromId,
|
|
110
|
+
gapUpToId,
|
|
111
|
+
tokenEstimate: gapTokenEstimate,
|
|
112
|
+
});
|
|
113
|
+
if (hasUI)
|
|
114
|
+
ui?.notify(`Observational memory: sync catch-up observer running on ~${gapTokenEstimate.toLocaleString()}-token gap`, "info");
|
|
115
|
+
progress.setPhase("observer", 1, 1);
|
|
116
|
+
updateWidget();
|
|
117
|
+
runtime.observerInFlight = true;
|
|
118
|
+
const gapCall = runObserver({
|
|
119
|
+
model: resolved.model,
|
|
120
|
+
apiKey: resolved.apiKey,
|
|
121
|
+
headers: resolved.headers,
|
|
122
|
+
priorReflections: memoryState.reflections.map(reflectionToPromptLine),
|
|
123
|
+
priorObservations: priorObservationLines,
|
|
124
|
+
chunk: gapChunk,
|
|
125
|
+
allowedSourceEntryIds: sourceEntryIds,
|
|
126
|
+
signal,
|
|
127
|
+
maxTurns: turnLimits.observerMaxTurnsPerRun,
|
|
128
|
+
});
|
|
129
|
+
const gapPromise = gapCall.then(() => undefined, () => undefined);
|
|
130
|
+
runtime.observerPromise = gapPromise;
|
|
131
|
+
try {
|
|
132
|
+
const records = await gapCall;
|
|
133
|
+
if (records && records.length > 0) {
|
|
134
|
+
const observationTokens = records.reduce((sum, r) => sum + estimateStringTokens(r.content), 0);
|
|
135
|
+
gapObservationData = {
|
|
136
|
+
records,
|
|
137
|
+
coversFromId: gapFromId,
|
|
138
|
+
coversUpToId: gapUpToId,
|
|
139
|
+
tokenCount: observationTokens,
|
|
140
|
+
};
|
|
141
|
+
debugLog("compaction.sync_catchup.records", {
|
|
142
|
+
count: records.length,
|
|
143
|
+
observationTokens,
|
|
144
|
+
coversFromId: gapFromId,
|
|
145
|
+
coversUpToId: gapUpToId,
|
|
146
|
+
records,
|
|
147
|
+
});
|
|
148
|
+
pi.appendEntry(OBSERVATION_CUSTOM_TYPE, gapObservationData);
|
|
149
|
+
if (hasUI && ui)
|
|
150
|
+
ui.notify(`Observational memory: sync catch-up recorded ${records.length} observation${records.length === 1 ? "" : "s"} (~${observationTokens.toLocaleString()} tokens)`, "info");
|
|
151
|
+
}
|
|
152
|
+
else if (hasUI && ui) {
|
|
153
|
+
debugLog("compaction.sync_catchup.empty", { gapEntryCount: gap.length });
|
|
154
|
+
ui.notify("Observational memory: sync catch-up observer returned empty — proceeding with compaction", "warning");
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
catch (error) {
|
|
158
|
+
const msg = error instanceof Error ? error.message : String(error);
|
|
159
|
+
debugLog("compaction.sync_catchup.error", { gapEntryCount: gap.length, errorMessage: msg });
|
|
160
|
+
if (hasUI && ui)
|
|
161
|
+
ui.notify(`Observational memory: sync catch-up observer failed: ${msg}. Cancelling compaction — ${gap.length} unobserved raw entries would be pruned without coverage. Try /compact again.`, "warning");
|
|
162
|
+
return { cancel: true };
|
|
163
|
+
}
|
|
164
|
+
finally {
|
|
165
|
+
runtime.observerInFlight = false;
|
|
166
|
+
if (runtime.observerPromise === gapPromise)
|
|
167
|
+
runtime.observerPromise = null;
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
const priorCompactionIdx = findLastCompactionIndex(entries);
|
|
172
|
+
const priorFirstKeptEntryId = priorCompactionIdx >= 0 ? entries[priorCompactionIdx].firstKeptEntryId : undefined;
|
|
173
|
+
const deltaObservationData = collectObservationsByCoverage(entries, priorFirstKeptEntryId, firstKeptEntryId);
|
|
174
|
+
if (gapObservationData)
|
|
175
|
+
deltaObservationData.push(gapObservationData);
|
|
176
|
+
debugLog("compaction.delta", {
|
|
177
|
+
priorFirstKeptEntryId,
|
|
178
|
+
firstKeptEntryId,
|
|
179
|
+
deltaObservationEntries: deltaObservationData.length,
|
|
180
|
+
deltaObservationRecords: deltaObservationData.reduce((sum, data) => sum + data.records.length, 0),
|
|
181
|
+
gapObservationRecords: gapObservationData?.records.length ?? 0,
|
|
182
|
+
});
|
|
183
|
+
if (deltaObservationData.length === 0) {
|
|
184
|
+
// No new observations since last compaction. If we have existing memory,
|
|
185
|
+
// carry it forward in a no-op compaction so it survives Pi's compaction.
|
|
186
|
+
// If there is truly nothing (no prior memory either), cancel.
|
|
187
|
+
if (memoryState.committedObs.length === 0 && memoryState.reflections.length === 0) {
|
|
188
|
+
debugLog("compaction.no_delta_cancel", {
|
|
189
|
+
committedObservations: memoryState.committedObs.length,
|
|
190
|
+
pendingObservations: memoryState.pendingObs.length,
|
|
191
|
+
reflections: memoryState.reflections.length,
|
|
192
|
+
});
|
|
193
|
+
if (hasUI) {
|
|
194
|
+
ui?.notify(`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`, "warning");
|
|
195
|
+
}
|
|
196
|
+
return { cancel: true };
|
|
197
|
+
}
|
|
198
|
+
// Carry forward existing memory without running reflector/pruner
|
|
199
|
+
const workingReflections = migrateLegacyReflections(memoryState.reflections);
|
|
200
|
+
debugLog("compaction.no_delta_carry_forward", {
|
|
201
|
+
observations: memoryState.committedObs.length,
|
|
202
|
+
reflections: workingReflections.length,
|
|
203
|
+
});
|
|
204
|
+
const summary = renderSummary(workingReflections, memoryState.committedObs);
|
|
205
|
+
const details = {
|
|
206
|
+
type: "observational-memory",
|
|
207
|
+
version: 4,
|
|
208
|
+
observations: memoryState.committedObs,
|
|
209
|
+
reflections: workingReflections,
|
|
210
|
+
};
|
|
211
|
+
if (hasUI)
|
|
212
|
+
ui?.notify(`Observational memory: no new observations — carrying forward ${memoryState.committedObs.length} observation${memoryState.committedObs.length === 1 ? "" : "s"}, ${workingReflections.length} reflection${workingReflections.length === 1 ? "" : "s"}`, "info");
|
|
213
|
+
return {
|
|
214
|
+
compaction: {
|
|
215
|
+
summary,
|
|
216
|
+
firstKeptEntryId,
|
|
217
|
+
tokensBefore,
|
|
218
|
+
details,
|
|
219
|
+
},
|
|
220
|
+
};
|
|
221
|
+
}
|
|
222
|
+
const workingReflections = migrateLegacyReflections(memoryState.reflections);
|
|
223
|
+
const workingObservations = [
|
|
224
|
+
...memoryState.committedObs,
|
|
225
|
+
...deltaObservationData.flatMap((d) => d.records),
|
|
226
|
+
];
|
|
227
|
+
const observationTokens = observationPoolTokens(workingObservations);
|
|
228
|
+
debugLog("compaction.reflect_prune.gate", {
|
|
229
|
+
observationTokens,
|
|
230
|
+
reflectionThresholdTokens: runtime.config.reflectionThresholdTokens,
|
|
231
|
+
willRun: observationTokens >= runtime.config.reflectionThresholdTokens,
|
|
232
|
+
workingObservations: workingObservations.length,
|
|
233
|
+
workingReflections: workingReflections.length,
|
|
234
|
+
});
|
|
235
|
+
let finalReflections = workingReflections;
|
|
236
|
+
let finalObservations = workingObservations;
|
|
237
|
+
if (observationTokens >= runtime.config.reflectionThresholdTokens) {
|
|
238
|
+
try {
|
|
239
|
+
debugLog("compaction.reflect_prune.start", {
|
|
240
|
+
workingObservations: workingObservations.length,
|
|
241
|
+
workingReflections: workingReflections.length,
|
|
242
|
+
observationTokens,
|
|
243
|
+
});
|
|
244
|
+
if (hasUI)
|
|
245
|
+
ui?.notify("Observational memory: running reflector + pruner...", "info");
|
|
246
|
+
progress.setPhase("reflector", 1, REFLECTOR_MAX_PASSES);
|
|
247
|
+
progress.setStartingCounts(workingReflections.length, workingObservations.length);
|
|
248
|
+
updateWidget();
|
|
249
|
+
const coverageBefore = coverageTagCounts(workingReflections, workingObservations);
|
|
250
|
+
const reflectorResult = await runReflector({ model: resolved.model, apiKey: resolved.apiKey, headers: resolved.headers, signal, onEvent: (event) => { progress.onEvent(event); updateWidget(); }, maxTurns: turnLimits.reflectorMaxTurnsPerPass }, workingReflections, workingObservations, (pass, max) => { progress.setPhase("reflector", pass, max); updateWidget(); });
|
|
251
|
+
finalReflections = reflectorResult.reflections;
|
|
252
|
+
const coverageAfter = coverageTagCounts(finalReflections, workingObservations);
|
|
253
|
+
debugLog("compaction.reflector.result", {
|
|
254
|
+
stats: reflectorResult.stats,
|
|
255
|
+
coverageBefore,
|
|
256
|
+
coverageAfter,
|
|
257
|
+
beforeReflections: workingReflections.length,
|
|
258
|
+
afterReflections: finalReflections.length,
|
|
259
|
+
});
|
|
260
|
+
const prunerResult = await runPruner({ model: resolved.model, apiKey: resolved.apiKey, headers: resolved.headers, signal, onEvent: (event) => { progress.onEvent(event); updateWidget(); }, maxTurns: turnLimits.prunerMaxTurnsPerPass }, finalReflections, workingObservations, runtime.config.reflectionThresholdTokens, (pass, max) => { progress.setPhase("pruner", pass, max); updateWidget(); });
|
|
261
|
+
finalObservations = prunerResult.observations;
|
|
262
|
+
debugLog("compaction.pruner.result", {
|
|
263
|
+
stopReason: prunerResult.stopReason,
|
|
264
|
+
fellBack: prunerResult.fellBack,
|
|
265
|
+
droppedIds: prunerResult.droppedIds,
|
|
266
|
+
passes: prunerResult.passes,
|
|
267
|
+
beforeObservations: workingObservations.length,
|
|
268
|
+
afterObservations: finalObservations.length,
|
|
269
|
+
});
|
|
270
|
+
updateWidget();
|
|
271
|
+
if (hasUI) {
|
|
272
|
+
ui?.notify(`Observational memory: diagnostics — ${formatReflectorStats(reflectorResult.stats)}; coverage ${formatCoverageCounts(coverageBefore)} → ${formatCoverageCounts(coverageAfter)}; ${formatPrunerStats(prunerResult)}`, "info");
|
|
273
|
+
}
|
|
274
|
+
if (prunerResult.fellBack && hasUI) {
|
|
275
|
+
ui?.notify("Observational memory: pruner run failed; kept observation set unchanged", "warning");
|
|
276
|
+
}
|
|
277
|
+
}
|
|
278
|
+
catch (error) {
|
|
279
|
+
const msg = error instanceof Error ? error.message : String(error);
|
|
280
|
+
debugLog("compaction.reflect_prune.error", { errorMessage: msg });
|
|
281
|
+
if (hasUI)
|
|
282
|
+
ui?.notify(`Observational memory: reflect/prune failed: ${msg}`, "warning");
|
|
283
|
+
}
|
|
284
|
+
}
|
|
285
|
+
const summary = renderSummary(finalReflections, finalObservations);
|
|
286
|
+
if (finalObservations.length === 0) {
|
|
287
|
+
throw new Error("invariant violated: finalObservations empty after delta guard");
|
|
288
|
+
}
|
|
289
|
+
const details = {
|
|
290
|
+
type: "observational-memory",
|
|
291
|
+
version: 4,
|
|
292
|
+
observations: finalObservations,
|
|
293
|
+
reflections: finalReflections,
|
|
294
|
+
};
|
|
295
|
+
debugLog("compaction.result", {
|
|
296
|
+
finalObservations: finalObservations.length,
|
|
297
|
+
finalReflections: finalReflections.length,
|
|
298
|
+
firstKeptEntryId,
|
|
299
|
+
tokensBefore,
|
|
300
|
+
});
|
|
301
|
+
if (hasUI)
|
|
302
|
+
ui?.notify(`Observational memory: compaction assembled — ${finalObservations.length} observation${finalObservations.length === 1 ? "" : "s"}, ${finalReflections.length} reflection${finalReflections.length === 1 ? "" : "s"}`, "info");
|
|
303
|
+
return {
|
|
304
|
+
compaction: {
|
|
305
|
+
summary,
|
|
306
|
+
firstKeptEntryId,
|
|
307
|
+
tokensBefore,
|
|
308
|
+
details,
|
|
309
|
+
},
|
|
310
|
+
};
|
|
311
|
+
});
|
|
312
|
+
}
|
|
313
|
+
finally {
|
|
314
|
+
runtime.compactHookInFlight = false;
|
|
315
|
+
progress.clear();
|
|
316
|
+
clearWidget();
|
|
317
|
+
}
|
|
318
|
+
});
|
|
319
|
+
}
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
import { rawTokensSinceLastCompaction } from "../branch.js";
|
|
2
|
+
/**
|
|
3
|
+
* Regex matching Pi's internal retryable error detection.
|
|
4
|
+
* When the last assistant message in agent_end has stopReason "error" matching this pattern,
|
|
5
|
+
* Pi will auto-retry — we must not trigger compaction between attempts.
|
|
6
|
+
*/
|
|
7
|
+
const RETRYABLE_ERROR_RE = /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;
|
|
8
|
+
export function registerCompactionTrigger(pi, runtime) {
|
|
9
|
+
pi.on("agent_end", (event, ctx) => {
|
|
10
|
+
runtime.ensureConfig(ctx.cwd);
|
|
11
|
+
if (runtime.config.passive === true)
|
|
12
|
+
return;
|
|
13
|
+
if (runtime.compactInFlight)
|
|
14
|
+
return;
|
|
15
|
+
// Don't trigger compaction if Pi will auto-retry — the agent hasn't truly finished.
|
|
16
|
+
// Pi emits agent_end before its own retry check, so we must detect this ourselves.
|
|
17
|
+
// The next agent_end (after retry succeeds or exhausts attempts) will re-evaluate.
|
|
18
|
+
const lastAssistant = [...event.messages].reverse().find((m) => m.role === "assistant");
|
|
19
|
+
if (lastAssistant
|
|
20
|
+
&& lastAssistant.stopReason === "error"
|
|
21
|
+
&& lastAssistant.errorMessage
|
|
22
|
+
&& RETRYABLE_ERROR_RE.test(lastAssistant.errorMessage)) {
|
|
23
|
+
return;
|
|
24
|
+
}
|
|
25
|
+
const entries = ctx.sessionManager.getBranch();
|
|
26
|
+
const tokens = rawTokensSinceLastCompaction(entries);
|
|
27
|
+
if (tokens < runtime.config.compactionThresholdTokens)
|
|
28
|
+
return;
|
|
29
|
+
// Capture ctx properties synchronously — the setTimeout + async work below
|
|
30
|
+
// may outlive the extension ctx (stale after session replacement/reload).
|
|
31
|
+
const hasUI = ctx.hasUI;
|
|
32
|
+
const ui = ctx.ui;
|
|
33
|
+
if (hasUI)
|
|
34
|
+
ui?.notify(`Observational memory: compaction threshold reached (~${tokens.toLocaleString()} tokens); triggering compaction`, "info");
|
|
35
|
+
runtime.compactInFlight = true;
|
|
36
|
+
setTimeout(async () => {
|
|
37
|
+
if (runtime.observerPromise) {
|
|
38
|
+
try {
|
|
39
|
+
await runtime.observerPromise;
|
|
40
|
+
}
|
|
41
|
+
catch {
|
|
42
|
+
// errors already surfaced via launchObserverTask
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
// After awaiting observerPromise, ctx may be stale.
|
|
46
|
+
// Use captured hasUI/ui for notification; wrap ctx access in try/catch.
|
|
47
|
+
try {
|
|
48
|
+
if (!ctx.isIdle()) {
|
|
49
|
+
runtime.compactInFlight = false;
|
|
50
|
+
if (hasUI)
|
|
51
|
+
ui?.notify("Observational memory: compaction deferred — agent became busy after observer wait", "info");
|
|
52
|
+
return;
|
|
53
|
+
}
|
|
54
|
+
const currentEntries = ctx.sessionManager.getBranch();
|
|
55
|
+
const currentTokens = rawTokensSinceLastCompaction(currentEntries);
|
|
56
|
+
if (currentTokens < runtime.config.compactionThresholdTokens) {
|
|
57
|
+
runtime.compactInFlight = false;
|
|
58
|
+
if (hasUI)
|
|
59
|
+
ui?.notify("Observational memory: compaction skipped — another compaction already ran during observer wait", "info");
|
|
60
|
+
return;
|
|
61
|
+
}
|
|
62
|
+
ctx.compact({
|
|
63
|
+
onComplete: () => {
|
|
64
|
+
runtime.compactInFlight = false;
|
|
65
|
+
if (hasUI)
|
|
66
|
+
ui?.notify("Observational memory: compaction complete", "info");
|
|
67
|
+
},
|
|
68
|
+
onError: (error) => {
|
|
69
|
+
runtime.compactInFlight = false;
|
|
70
|
+
if (error.message === "Compaction cancelled") {
|
|
71
|
+
// We already notified the user with the real reason before returning { cancel: true }.
|
|
72
|
+
return;
|
|
73
|
+
}
|
|
74
|
+
if (hasUI)
|
|
75
|
+
ui?.notify(`Observational memory: ${error.message}`, "error");
|
|
76
|
+
},
|
|
77
|
+
});
|
|
78
|
+
}
|
|
79
|
+
catch (error) {
|
|
80
|
+
runtime.compactInFlight = false;
|
|
81
|
+
const msg = error instanceof Error ? error.message : String(error);
|
|
82
|
+
if (hasUI)
|
|
83
|
+
ui?.notify(`Observational memory: compact threw: ${msg}`, "error");
|
|
84
|
+
}
|
|
85
|
+
}, 0);
|
|
86
|
+
});
|
|
87
|
+
}
|
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
import { debugLog, withDebugLogContext } from "../debug-log.js";
|
|
2
|
+
import { resolveTurnLimits } from "../config.js";
|
|
3
|
+
import { firstRawIdAfter, getMemoryState, lastObservationCoverEndIdx, rawTailEntriesBetween, rawTokensSinceLastBound, } from "../branch.js";
|
|
4
|
+
import { observationsToPromptLines, runObserver } from "../observer.js";
|
|
5
|
+
import { serializeSourceAddressedBranchEntries } from "../serialize.js";
|
|
6
|
+
import { estimateStringTokens } from "../tokens.js";
|
|
7
|
+
import { OBSERVATION_CUSTOM_TYPE, reflectionToPromptLine } from "../types.js";
|
|
8
|
+
export function registerObserverTrigger(pi, runtime) {
|
|
9
|
+
pi.on("turn_end", (_event, ctx) => {
|
|
10
|
+
runtime.ensureConfig(ctx.cwd);
|
|
11
|
+
if (runtime.config.passive === true)
|
|
12
|
+
return;
|
|
13
|
+
if (runtime.observerInFlight)
|
|
14
|
+
return;
|
|
15
|
+
const entries = ctx.sessionManager.getBranch();
|
|
16
|
+
const tokens = rawTokensSinceLastBound(entries);
|
|
17
|
+
if (tokens < runtime.config.observationThresholdTokens)
|
|
18
|
+
return;
|
|
19
|
+
const lastBoundIdx = lastObservationCoverEndIdx(entries);
|
|
20
|
+
const coversFromId = firstRawIdAfter(entries, lastBoundIdx);
|
|
21
|
+
if (!coversFromId)
|
|
22
|
+
return;
|
|
23
|
+
const leafId = ctx.sessionManager.getLeafId();
|
|
24
|
+
if (!leafId)
|
|
25
|
+
return;
|
|
26
|
+
const coversUpToId = leafId;
|
|
27
|
+
const { reflections, committedObs, pendingObs } = getMemoryState(entries);
|
|
28
|
+
const priorObservationLines = observationsToPromptLines([...committedObs, ...pendingObs]);
|
|
29
|
+
const turnLimits = resolveTurnLimits(runtime.config);
|
|
30
|
+
const chunkEntries = rawTailEntriesBetween(entries, coversFromId, coversUpToId);
|
|
31
|
+
if (chunkEntries.length === 0)
|
|
32
|
+
return;
|
|
33
|
+
const { text: chunk, sourceEntryIds } = serializeSourceAddressedBranchEntries(chunkEntries);
|
|
34
|
+
if (!chunk.trim() || sourceEntryIds.length === 0)
|
|
35
|
+
return;
|
|
36
|
+
if (ctx.hasUI)
|
|
37
|
+
ctx.ui.notify(`Observational memory: observer running on ~${tokens.toLocaleString()}-token chunk`, "info");
|
|
38
|
+
const runId = `observer-${Date.now().toString(36)}-${Math.random().toString(16).slice(2, 8)}`;
|
|
39
|
+
// Capture ctx properties synchronously — the async work below may outlive
|
|
40
|
+
// the extension ctx (stale after session replacement/reload).
|
|
41
|
+
const hasUI = ctx.hasUI;
|
|
42
|
+
const ui = ctx.ui;
|
|
43
|
+
const model = ctx.model;
|
|
44
|
+
const modelRegistry = ctx.modelRegistry;
|
|
45
|
+
const cwd = ctx.cwd;
|
|
46
|
+
void runtime.launchObserverTask(ctx, "observer", async () => withDebugLogContext({ enabled: runtime.config.debugLog === true, cwd, runId }, async () => {
|
|
47
|
+
try {
|
|
48
|
+
debugLog("observer.start", {
|
|
49
|
+
tokens,
|
|
50
|
+
coversFromId,
|
|
51
|
+
coversUpToId,
|
|
52
|
+
sourceEntryIds,
|
|
53
|
+
sourceEntryCount: sourceEntryIds.length,
|
|
54
|
+
priorReflections: reflections.length,
|
|
55
|
+
priorObservations: priorObservationLines.length,
|
|
56
|
+
});
|
|
57
|
+
const resolved = await runtime.resolveModel({ model, modelRegistry, hasUI, ui });
|
|
58
|
+
if (!resolved.ok) {
|
|
59
|
+
debugLog("observer.model_unavailable", { reason: resolved.reason });
|
|
60
|
+
if (!runtime.resolveFailureNotified && hasUI && ui) {
|
|
61
|
+
ui.notify(`Observational memory: observer skipped — ${resolved.reason}`, "warning");
|
|
62
|
+
runtime.resolveFailureNotified = true;
|
|
63
|
+
}
|
|
64
|
+
return;
|
|
65
|
+
}
|
|
66
|
+
runtime.resolveFailureNotified = false;
|
|
67
|
+
const records = await runObserver({
|
|
68
|
+
model: resolved.model,
|
|
69
|
+
apiKey: resolved.apiKey,
|
|
70
|
+
headers: resolved.headers,
|
|
71
|
+
priorReflections: reflections.map(reflectionToPromptLine),
|
|
72
|
+
priorObservations: priorObservationLines,
|
|
73
|
+
chunk,
|
|
74
|
+
allowedSourceEntryIds: sourceEntryIds,
|
|
75
|
+
maxTurns: turnLimits.observerMaxTurnsPerRun,
|
|
76
|
+
});
|
|
77
|
+
if (!records || records.length === 0) {
|
|
78
|
+
debugLog("observer.empty", { coversFromId, coversUpToId });
|
|
79
|
+
if (hasUI && ui)
|
|
80
|
+
ui.notify("Observational memory: observer returned no observations", "warning");
|
|
81
|
+
return;
|
|
82
|
+
}
|
|
83
|
+
const observationTokens = records.reduce((sum, r) => sum + estimateStringTokens(r.content), 0);
|
|
84
|
+
const data = {
|
|
85
|
+
records,
|
|
86
|
+
coversFromId,
|
|
87
|
+
coversUpToId,
|
|
88
|
+
tokenCount: observationTokens,
|
|
89
|
+
};
|
|
90
|
+
debugLog("observer.records", {
|
|
91
|
+
count: records.length,
|
|
92
|
+
observationTokens,
|
|
93
|
+
coversFromId,
|
|
94
|
+
coversUpToId,
|
|
95
|
+
records,
|
|
96
|
+
});
|
|
97
|
+
pi.appendEntry(OBSERVATION_CUSTOM_TYPE, data);
|
|
98
|
+
debugLog("observer.appended", { count: records.length, tokenCount: observationTokens, coversFromId, coversUpToId });
|
|
99
|
+
if (hasUI && ui)
|
|
100
|
+
ui.notify(`Observational memory: ${records.length} observation${records.length === 1 ? "" : "s"} recorded (~${observationTokens.toLocaleString()} tokens)`, "info");
|
|
101
|
+
}
|
|
102
|
+
catch (error) {
|
|
103
|
+
debugLog("observer.error", { errorMessage: error instanceof Error ? error.message : String(error) });
|
|
104
|
+
throw error;
|
|
105
|
+
}
|
|
106
|
+
}));
|
|
107
|
+
});
|
|
108
|
+
}
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
import { registerStatusCommand } from "./commands/status.js";
|
|
2
|
+
import { registerViewCommand } from "./commands/view.js";
|
|
3
|
+
import { registerCompactionHook } from "./hooks/compaction-hook.js";
|
|
4
|
+
import { registerCompactionTrigger } from "./hooks/compaction-trigger.js";
|
|
5
|
+
import { registerObserverTrigger } from "./hooks/observer-trigger.js";
|
|
6
|
+
import { Runtime } from "./runtime.js";
|
|
7
|
+
import { registerRecallTool } from "./tools/recall-observation.js";
|
|
8
|
+
export default function observationalMemory(pi) {
|
|
9
|
+
const runtime = new Runtime();
|
|
10
|
+
registerObserverTrigger(pi, runtime);
|
|
11
|
+
registerCompactionTrigger(pi, runtime);
|
|
12
|
+
registerCompactionHook(pi, runtime);
|
|
13
|
+
registerStatusCommand(pi, runtime);
|
|
14
|
+
registerViewCommand(pi, runtime);
|
|
15
|
+
registerRecallTool(pi);
|
|
16
|
+
}
|