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 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
@@ -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 ensureState(ctx);
500
- await refreshCounts(ctx);
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 || state.status === "failed") return;
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
- console.error(`[OM] Error: Failed to parse state and backup for ${legacyStatePath}. Falling back to default empty state.`);
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
- console.error(`[OM] Error: Failed to parse state for ${legacyStatePath} (no backup). Falling back to default empty state.`);
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
- state.pendingMessageTokens = estimateEntriesTokens(pending);
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) return;
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: ${errorMessage(error)}`;
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 = Math.min(999, Math.round((state.pendingMessageTokens / effectiveObservationThreshold(state)) * 100));
2005
- const memPct = Math.min(999, Math.round((state.observationTokens / state.thresholds.reflection) * 100));
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 = Math.min(999, Math.round((state.pendingMessageTokens / effectiveObservationThreshold(state)) * 100));
2015
- const memPct = Math.min(999, Math.round((state.observationTokens / state.thresholds.reflection) * 100));
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 >= 80 ? `\x1b[1;91m${msgPct}%\x1b[0m` : msgPct >= 50 ? `\x1b[1;93m${msgPct}%\x1b[0m` : `\x1b[1;32m${msgPct}%\x1b[0m`;
2023
- const memPctStr = memPct >= 80 ? `\x1b[1;91m${memPct}%\x1b[0m` : memPct >= 50 ? `\x1b[1;93m${memPct}%\x1b[0m` : `\x1b[1;32m${memPct}%\x1b[0m`;
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.0",
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>",