pi-observational-memory-extension 0.2.0 → 0.2.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.
- package/CHANGELOG.md +13 -0
- package/extensions/index.ts +116 -16
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -23,6 +23,19 @@ When preparing a new release:
|
|
|
23
23
|
|
|
24
24
|
---
|
|
25
25
|
|
|
26
|
+
## [0.2.1] - 2026-06-23
|
|
27
|
+
|
|
28
|
+
### Fixed
|
|
29
|
+
- **status**: Restored the colored OM footer metrics after reloads and session restarts.
|
|
30
|
+
- **status**: Prevented failed OM states from permanently disabling future observation attempts.
|
|
31
|
+
- **status**: Clamped footer percentages to readable `100%+` overflow instead of noisy `999%` values.
|
|
32
|
+
- **observer**: Forced/block-after observation now processes bounded chunks instead of sending the entire backlog to the Observer.
|
|
33
|
+
- **observer**: Automatically recovers from oversized stale backlogs by retaining the latest tail-window and recording a diagnostic observation.
|
|
34
|
+
- **observer**: Treats empty observer output and stale reload races as non-fatal skips that advance the cursor safely.
|
|
35
|
+
- **ui**: Added resilient status bootstrapping on `session_start`, `agent_start`, and `turn_start`.
|
|
36
|
+
- **recovery**: Shows an explicit OM footer error if state recovery fails instead of leaving the status area empty.
|
|
37
|
+
- **recovery**: Removed stdout/stderr recovery logging so internal extension diagnostics do not leak into chat transcripts.
|
|
38
|
+
|
|
26
39
|
## [0.2.0] - 2026-06-23
|
|
27
40
|
|
|
28
41
|
### Added
|
package/extensions/index.ts
CHANGED
|
@@ -20,6 +20,8 @@ const DEFAULT_REFLECTION_MODEL = "google/gemini-2.5-flash";
|
|
|
20
20
|
const OBSERVATION_THRESHOLD = 30_000;
|
|
21
21
|
const REFLECTION_THRESHOLD = 40_000;
|
|
22
22
|
const OBSERVER_MAX_BATCH_TOKENS = 10_000;
|
|
23
|
+
const MAX_PENDING_BACKLOG_TOKENS = OBSERVER_MAX_BATCH_TOKENS * 12;
|
|
24
|
+
const RETAIN_PENDING_TAIL_TOKENS = OBSERVER_MAX_BATCH_TOKENS * 3;
|
|
23
25
|
const BUFFER_TOKENS_RATIO = 0.2;
|
|
24
26
|
const BUFFER_ACTIVATION_RATIO = 0.8;
|
|
25
27
|
const DEFAULT_BLOCK_AFTER_MULTIPLIER = 1.2;
|
|
@@ -496,9 +498,8 @@ export default function (pi: ExtensionAPI) {
|
|
|
496
498
|
});
|
|
497
499
|
|
|
498
500
|
pi.on("session_start", async (_event, ctx) => {
|
|
499
|
-
await
|
|
500
|
-
|
|
501
|
-
updateStatus(ctx);
|
|
501
|
+
await bootstrapStatus(ctx, "session_start");
|
|
502
|
+
if (runtime.statusTimer) clearInterval(runtime.statusTimer);
|
|
502
503
|
runtime.statusTimer = setInterval(() => updateStatus(ctx), 1000);
|
|
503
504
|
|
|
504
505
|
if (!checkedForUpdate) {
|
|
@@ -509,6 +510,14 @@ export default function (pi: ExtensionAPI) {
|
|
|
509
510
|
}
|
|
510
511
|
});
|
|
511
512
|
|
|
513
|
+
pi.on("agent_start", async (_event, ctx) => {
|
|
514
|
+
await bootstrapStatus(ctx, "agent_start");
|
|
515
|
+
});
|
|
516
|
+
|
|
517
|
+
pi.on("turn_start", async (_event, ctx) => {
|
|
518
|
+
await bootstrapStatus(ctx, "turn_start");
|
|
519
|
+
});
|
|
520
|
+
|
|
512
521
|
pi.on("session_shutdown", async (_event, ctx) => {
|
|
513
522
|
if (runtime.statusTimer) clearInterval(runtime.statusTimer);
|
|
514
523
|
runtime.currentOperation?.abort();
|
|
@@ -810,7 +819,13 @@ async function safeBoundary(ctx: any, boundary: string): Promise<void> {
|
|
|
810
819
|
if (isStale) return;
|
|
811
820
|
|
|
812
821
|
const state = await ensureState(ctx);
|
|
813
|
-
if (!state.enabled
|
|
822
|
+
if (!state.enabled) return;
|
|
823
|
+
if (state.status === "failed") {
|
|
824
|
+
state.status = "idle";
|
|
825
|
+
state.lastError = undefined;
|
|
826
|
+
state.operationLock = undefined;
|
|
827
|
+
await saveState(state);
|
|
828
|
+
}
|
|
814
829
|
await refreshCounts(ctx);
|
|
815
830
|
updateStatus(ctx);
|
|
816
831
|
if (runtime.currentOperation) return;
|
|
@@ -871,6 +886,7 @@ async function ensureState(ctx: any): Promise<PiOMRecord> {
|
|
|
871
886
|
runtime.debugDir = debugDir;
|
|
872
887
|
let state: PiOMRecord | undefined;
|
|
873
888
|
let statePath = legacyStatePath;
|
|
889
|
+
let recoveryWarning: string | undefined;
|
|
874
890
|
if (existsSync(legacyStatePath)) {
|
|
875
891
|
try {
|
|
876
892
|
state = normalizeState(JSON.parse(await readFile(legacyStatePath, "utf8")), { sessionId, sessionFile, cwd: ctx.cwd });
|
|
@@ -880,14 +896,14 @@ async function ensureState(ctx: any): Promise<PiOMRecord> {
|
|
|
880
896
|
try {
|
|
881
897
|
state = normalizeState(JSON.parse(await readFile(backupPath, "utf8")), { sessionId, sessionFile, cwd: ctx.cwd });
|
|
882
898
|
} catch {
|
|
883
|
-
|
|
899
|
+
recoveryWarning = `Recovered from corrupted OM state and backup at ${legacyStatePath}; moved them to .corrupted files.`;
|
|
884
900
|
try {
|
|
885
901
|
await rename(legacyStatePath, `${legacyStatePath}.corrupted`);
|
|
886
902
|
await rename(backupPath, `${backupPath}.corrupted`);
|
|
887
903
|
} catch {}
|
|
888
904
|
}
|
|
889
905
|
} else {
|
|
890
|
-
|
|
906
|
+
recoveryWarning = `Recovered from corrupted OM state at ${legacyStatePath}; moved it to a .corrupted file.`;
|
|
891
907
|
try {
|
|
892
908
|
await rename(legacyStatePath, `${legacyStatePath}.corrupted`);
|
|
893
909
|
} catch {}
|
|
@@ -896,6 +912,7 @@ async function ensureState(ctx: any): Promise<PiOMRecord> {
|
|
|
896
912
|
}
|
|
897
913
|
if (!state) {
|
|
898
914
|
state = createDefaultState({ sessionId, sessionFile, cwd: ctx.cwd });
|
|
915
|
+
if (recoveryWarning) state.lastError = recoveryWarning;
|
|
899
916
|
}
|
|
900
917
|
statePath = statePathFor(ctx.cwd, state.scope, sessionId);
|
|
901
918
|
if (statePath !== legacyStatePath && existsSync(statePath)) {
|
|
@@ -1162,14 +1179,57 @@ async function refreshCounts(ctx: any): Promise<void> {
|
|
|
1162
1179
|
const state = await ensureState(ctx);
|
|
1163
1180
|
const branch = ctx.sessionManager.getBranch() as SessionEntry[];
|
|
1164
1181
|
const pending = entriesAfter(branch, state.lastObservedEntryId).filter(isMessageLikeEntry);
|
|
1165
|
-
|
|
1182
|
+
const pendingTokens = estimateEntriesTokens(pending);
|
|
1183
|
+
if (pendingTokens > MAX_PENDING_BACKLOG_TOKENS) {
|
|
1184
|
+
const retained = takeTailEntriesUpTo(pending, RETAIN_PENDING_TAIL_TOKENS);
|
|
1185
|
+
const firstRetained = retained[0];
|
|
1186
|
+
const firstRetainedIndex = firstRetained ? pending.findIndex(e => e.id === firstRetained.id) : -1;
|
|
1187
|
+
const cursor = firstRetainedIndex > 0 ? pending[firstRetainedIndex - 1] : undefined;
|
|
1188
|
+
if (cursor?.id) state.lastObservedEntryId = cursor.id;
|
|
1189
|
+
const retainedTokens = estimateEntriesTokens(retained);
|
|
1190
|
+
const skippedTokens = Math.max(0, pendingTokens - retainedTokens);
|
|
1191
|
+
const note = `OM recovery: skipped ~${formatTokens(skippedTokens)} tokens of stale unobserved backlog after reload/corruption recovery; retained latest ~${formatTokens(retainedTokens)} tokens for observation.`;
|
|
1192
|
+
state.observations = suppressDuplicateObservations(state.observations, `- ${note}`);
|
|
1193
|
+
state.lastOperation = { type: "observation", startedAt: new Date().toISOString(), endedAt: new Date().toISOString(), inputTokens: 0, outputTokens: estimateTokens(note) };
|
|
1194
|
+
state.lastError = note;
|
|
1195
|
+
state.pendingMessageTokens = retainedTokens;
|
|
1196
|
+
} else {
|
|
1197
|
+
state.pendingMessageTokens = pendingTokens;
|
|
1198
|
+
}
|
|
1166
1199
|
state.observationTokens = estimateTokens(state.observations);
|
|
1167
1200
|
await saveState(state);
|
|
1168
1201
|
}
|
|
1169
1202
|
|
|
1203
|
+
async function bootstrapStatus(ctx: any, reason: string): Promise<void> {
|
|
1204
|
+
try {
|
|
1205
|
+
const state = await ensureState(ctx);
|
|
1206
|
+
updateStatus(ctx);
|
|
1207
|
+
try {
|
|
1208
|
+
await refreshCounts(ctx);
|
|
1209
|
+
} catch (error) {
|
|
1210
|
+
state.lastError = `OM status refresh failed during ${reason}: ${errorMessage(error)}`;
|
|
1211
|
+
await saveState(state);
|
|
1212
|
+
}
|
|
1213
|
+
updateStatus(ctx);
|
|
1214
|
+
} catch (error) {
|
|
1215
|
+
try {
|
|
1216
|
+
ctx.ui.setStatus("om", `\x1b[31mOM error\x1b[0m \x1b[2m${truncateToWidth(errorMessage(error), 80, "…")}\x1b[0m`);
|
|
1217
|
+
} catch {
|
|
1218
|
+
// Context might be stale or UI disposed
|
|
1219
|
+
}
|
|
1220
|
+
}
|
|
1221
|
+
}
|
|
1222
|
+
|
|
1170
1223
|
function updateStatus(ctx: any): void {
|
|
1171
1224
|
const state = runtime.state;
|
|
1172
|
-
if (!state)
|
|
1225
|
+
if (!state) {
|
|
1226
|
+
try {
|
|
1227
|
+
ctx.ui.setStatus("om", "\x1b[2;37mOM loading…\x1b[0m");
|
|
1228
|
+
} catch {
|
|
1229
|
+
// Context might be stale or UI disposed
|
|
1230
|
+
}
|
|
1231
|
+
return;
|
|
1232
|
+
}
|
|
1173
1233
|
try {
|
|
1174
1234
|
ctx.ui.setStatus("om", formatShortStatusColored(state));
|
|
1175
1235
|
} catch (e) {
|
|
@@ -1192,7 +1252,7 @@ async function observeNow(ctx: any, opts: { force: boolean; reason: string; sign
|
|
|
1192
1252
|
const branch = ctx.sessionManager.getBranch() as SessionEntry[];
|
|
1193
1253
|
const candidates = entriesAfter(branch, state.lastObservedEntryId).filter(isMessageLikeEntry);
|
|
1194
1254
|
if (candidates.length === 0 && !opts.manualText) return;
|
|
1195
|
-
const selected = opts.force ? candidates : selectEntriesForObservation(candidates, state);
|
|
1255
|
+
const selected = opts.force ? selectEntriesForForcedObservation(candidates) : selectEntriesForObservation(candidates, state);
|
|
1196
1256
|
if (selected.length === 0 && !opts.manualText) return;
|
|
1197
1257
|
const observerInput = opts.manualText
|
|
1198
1258
|
? { text: opts.manualText, attachmentParts: [] as any[], omissions: [] as string[] }
|
|
@@ -1218,9 +1278,21 @@ async function observeNow(ctx: any, opts: { force: boolean; reason: string; sign
|
|
|
1218
1278
|
await writeDebug(ctx, "observation", { startedAt, reason: opts.reason, inputText, attachmentOmissions: observerInput.omissions, attachmentCount: observerInput.attachmentParts.length, rawOutput: result.rawOutput, parsed: result });
|
|
1219
1279
|
await saveState(state);
|
|
1220
1280
|
} catch (error) {
|
|
1281
|
+
const message = errorMessage(error);
|
|
1282
|
+
const last = selected.at(-1);
|
|
1283
|
+
if (!opts.manualText && (message.includes("Observer returned no observations") || message.includes("ctx is stale"))) {
|
|
1284
|
+
if (last?.id) state.lastObservedEntryId = last.id;
|
|
1285
|
+
state.pendingMessageTokens = Math.max(0, state.pendingMessageTokens - estimateEntriesTokens(selected));
|
|
1286
|
+
state.operationLock = undefined;
|
|
1287
|
+
state.status = "idle";
|
|
1288
|
+
state.lastError = undefined;
|
|
1289
|
+
state.lastOperation = { type: "observation", startedAt, endedAt: new Date().toISOString(), inputTokens: estimateTokens(inputText), error: `Non-fatal observer skip: ${message}` };
|
|
1290
|
+
await saveState(state);
|
|
1291
|
+
return;
|
|
1292
|
+
}
|
|
1221
1293
|
state.operationLock = undefined;
|
|
1222
1294
|
state.status = "failed";
|
|
1223
|
-
state.lastError = `Observational Memory Observer failed: ${
|
|
1295
|
+
state.lastError = `Observational Memory Observer failed: ${message}`;
|
|
1224
1296
|
state.lastOperation = { type: "observation", startedAt, endedAt: new Date().toISOString(), inputTokens: estimateTokens(inputText), error: state.lastError };
|
|
1225
1297
|
await saveState(state);
|
|
1226
1298
|
throw new Error(state.lastError);
|
|
@@ -1737,6 +1809,10 @@ function selectEntriesForObservation(entries: SessionEntry[], state: PiOMRecord)
|
|
|
1737
1809
|
return takeEntriesUpTo(entries, Math.min(target, OBSERVER_MAX_BATCH_TOKENS));
|
|
1738
1810
|
}
|
|
1739
1811
|
|
|
1812
|
+
function selectEntriesForForcedObservation(entries: SessionEntry[]): SessionEntry[] {
|
|
1813
|
+
return takeEntriesUpTo(entries, OBSERVER_MAX_BATCH_TOKENS);
|
|
1814
|
+
}
|
|
1815
|
+
|
|
1740
1816
|
function takeEntriesUpTo(entries: SessionEntry[], targetTokens: number): SessionEntry[] {
|
|
1741
1817
|
const selected: SessionEntry[] = [];
|
|
1742
1818
|
let total = 0;
|
|
@@ -1748,6 +1824,18 @@ function takeEntriesUpTo(entries: SessionEntry[], targetTokens: number): Session
|
|
|
1748
1824
|
return selected;
|
|
1749
1825
|
}
|
|
1750
1826
|
|
|
1827
|
+
function takeTailEntriesUpTo(entries: SessionEntry[], targetTokens: number): SessionEntry[] {
|
|
1828
|
+
const selected: SessionEntry[] = [];
|
|
1829
|
+
let total = 0;
|
|
1830
|
+
for (let i = entries.length - 1; i >= 0; i--) {
|
|
1831
|
+
const entry = entries[i];
|
|
1832
|
+
selected.unshift(entry);
|
|
1833
|
+
total += estimateEntryTokens(entry);
|
|
1834
|
+
if (total >= targetTokens) break;
|
|
1835
|
+
}
|
|
1836
|
+
return selected;
|
|
1837
|
+
}
|
|
1838
|
+
|
|
1751
1839
|
function entriesAfter(branch: SessionEntry[], id?: string): SessionEntry[] {
|
|
1752
1840
|
if (!id) return branch;
|
|
1753
1841
|
const idx = branch.findIndex(e => e.id === id);
|
|
@@ -2001,8 +2089,8 @@ function responseText(response: any): string {
|
|
|
2001
2089
|
|
|
2002
2090
|
function formatShortStatus(state: PiOMRecord): string {
|
|
2003
2091
|
if (!state.enabled) return "om: disabled";
|
|
2004
|
-
const msgPct =
|
|
2005
|
-
const memPct =
|
|
2092
|
+
const msgPct = boundedPercent(state.pendingMessageTokens, effectiveObservationThreshold(state));
|
|
2093
|
+
const memPct = boundedPercent(state.observationTokens, state.thresholds.reflection);
|
|
2006
2094
|
const buffer = state.buffered.observations.length ? ` | buf ${state.buffered.observations.length} ↓${formatTokens(state.buffered.observations.reduce((s, c) => s + c.messageTokens, 0))}` : "";
|
|
2007
2095
|
const err = state.status === "failed" && state.lastError ? ` | ${truncateText(state.lastError, 50).replace(/\n/g, " ")}` : "";
|
|
2008
2096
|
return `om: ${state.status} | msg ${formatTokens(state.pendingMessageTokens)}/${formatTokens(effectiveObservationThreshold(state))} ${msgPct}% | mem ${formatTokens(state.observationTokens)}/${formatTokens(state.thresholds.reflection)} ${memPct}%${buffer}${err}`;
|
|
@@ -2011,16 +2099,16 @@ function formatShortStatus(state: PiOMRecord): string {
|
|
|
2011
2099
|
function formatShortStatusColored(state: PiOMRecord): string {
|
|
2012
2100
|
if (!state.enabled) return "\x1b[2;31mom: disabled\x1b[0m";
|
|
2013
2101
|
|
|
2014
|
-
const msgPct =
|
|
2015
|
-
const memPct =
|
|
2102
|
+
const msgPct = boundedPercent(state.pendingMessageTokens, effectiveObservationThreshold(state));
|
|
2103
|
+
const memPct = boundedPercent(state.observationTokens, state.thresholds.reflection);
|
|
2016
2104
|
|
|
2017
2105
|
let statusColor = "\x1b[32m"; // green for idle
|
|
2018
2106
|
if (state.status === "failed") statusColor = "\x1b[91m";
|
|
2019
2107
|
else if (state.status !== "idle") statusColor = "\x1b[93m"; // yellow for active ops
|
|
2020
2108
|
|
|
2021
2109
|
// Highlight percentages
|
|
2022
|
-
const msgPctStr = msgPct
|
|
2023
|
-
const memPctStr = memPct
|
|
2110
|
+
const msgPctStr = formatPercentColored(msgPct);
|
|
2111
|
+
const memPctStr = formatPercentColored(memPct);
|
|
2024
2112
|
|
|
2025
2113
|
const buffer = state.buffered.observations.length
|
|
2026
2114
|
? ` \x1b[2;37m|\x1b[0m buf \x1b[1;36m${state.buffered.observations.length}\x1b[0m \x1b[36m↓${formatTokens(state.buffered.observations.reduce((s, c) => s + c.messageTokens, 0))}\x1b[0m`
|
|
@@ -2033,6 +2121,18 @@ function formatShortStatusColored(state: PiOMRecord): string {
|
|
|
2033
2121
|
return `om: ${statusColor}${state.status}\x1b[0m \x1b[2;37m|\x1b[0m msg \x1b[1;33m${formatTokens(state.pendingMessageTokens)}\x1b[0m/\x1b[2;37m${formatTokens(effectiveObservationThreshold(state))}\x1b[0m [${msgPctStr}] \x1b[2;37m|\x1b[0m mem \x1b[1;36m${formatTokens(state.observationTokens)}\x1b[0m/\x1b[2;37m${formatTokens(state.thresholds.reflection)}\x1b[0m [${memPctStr}]${buffer}${err}`;
|
|
2034
2122
|
}
|
|
2035
2123
|
|
|
2124
|
+
function boundedPercent(value: number, total: number): number {
|
|
2125
|
+
if (!Number.isFinite(value) || !Number.isFinite(total) || total <= 0) return 0;
|
|
2126
|
+
return Math.min(100, Math.max(0, Math.round((value / total) * 100)));
|
|
2127
|
+
}
|
|
2128
|
+
|
|
2129
|
+
function formatPercentColored(percent: number): string {
|
|
2130
|
+
const suffix = percent >= 100 ? "%+" : "%";
|
|
2131
|
+
if (percent >= 80) return `\x1b[1;91m${percent}${suffix}\x1b[0m`;
|
|
2132
|
+
if (percent >= 50) return `\x1b[1;93m${percent}${suffix}\x1b[0m`;
|
|
2133
|
+
return `\x1b[1;32m${percent}${suffix}\x1b[0m`;
|
|
2134
|
+
}
|
|
2135
|
+
|
|
2036
2136
|
function parsePositiveIntSetting(key: string, value: string): number {
|
|
2037
2137
|
const n = Number(value);
|
|
2038
2138
|
if (!Number.isFinite(n) || n <= 0) throw new Error(`OM setting ${key} must be a positive number`);
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "pi-observational-memory-extension",
|
|
3
|
-
"version": "0.2.
|
|
3
|
+
"version": "0.2.1",
|
|
4
4
|
"description": "Mastra-style Observational Memory extension for Pi compaction and runtime context.",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"author": "Nikita Nosov <20nik.nosov21@gmail.com>",
|