@aexol/spectral 0.4.8 → 0.4.9

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.
@@ -7,6 +7,7 @@ export const DEFAULTS = {
7
7
  reflectionThresholdTokens: 30_000,
8
8
  passive: false,
9
9
  debugLog: false,
10
+ observerMaxChunkTokens: 30_000,
10
11
  };
11
12
  const SETTINGS_KEY = "observational-memory";
12
13
  const PASSIVE_ENV = "PI_OBSERVATIONAL_MEMORY_PASSIVE";
@@ -22,6 +23,7 @@ function normalizeTurnLimit(normalized, key) {
22
23
  delete normalized[key];
23
24
  }
24
25
  else {
26
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
25
27
  normalized[key] = value;
26
28
  }
27
29
  }
@@ -32,6 +34,7 @@ function normalizeSettingsConfig(value) {
32
34
  if ("debugLog" in normalized && typeof normalized.debugLog !== "boolean")
33
35
  delete normalized.debugLog;
34
36
  normalizeTurnLimit(normalized, "observerMaxTurnsPerRun");
37
+ normalizeTurnLimit(normalized, "observerMaxChunkTokens");
35
38
  normalizeTurnLimit(normalized, "reflectorMaxTurnsPerPass");
36
39
  normalizeTurnLimit(normalized, "prunerMaxTurnsPerPass");
37
40
  normalizeTurnLimit(normalized, "compactionMaxToolCalls");
@@ -3,7 +3,7 @@ import { resolveTurnLimits } from "../config.js";
3
3
  import { firstRawIdAfter, getMemoryState, lastObservationCoverEndIdx, rawTailEntriesBetween, rawTokensSinceLastBound, } from "../branch.js";
4
4
  import { observationsToPromptLines, runObserver } from "../observer.js";
5
5
  import { serializeSourceAddressedBranchEntries } from "../serialize.js";
6
- import { estimateStringTokens } from "../tokens.js";
6
+ import { estimateEntryTokens, estimateStringTokens } from "../tokens.js";
7
7
  import { OBSERVATION_CUSTOM_TYPE, reflectionToPromptLine } from "../types.js";
8
8
  export function registerObserverTrigger(pi, runtime) {
9
9
  pi.on("turn_end", (_event, ctx) => {
@@ -35,9 +35,40 @@ export function registerObserverTrigger(pi, runtime) {
35
35
  const { reflections, committedObs, pendingObs } = getMemoryState(entries);
36
36
  const priorObservationLines = observationsToPromptLines([...committedObs, ...pendingObs]);
37
37
  const turnLimits = resolveTurnLimits(runtime.config);
38
- const chunkEntries = rawTailEntriesBetween(entries, coversFromId, coversUpToId);
38
+ let chunkEntries = rawTailEntriesBetween(entries, coversFromId, coversUpToId);
39
39
  if (chunkEntries.length === 0)
40
40
  return;
41
+ // Cap chunk size to prevent observer overload on large sessions.
42
+ // Without capping, returning to an old big session can produce a 159K+ token
43
+ // chunk that the observer cannot process in 16 turns, causing it to return
44
+ // empty. The bound never advances, so future attempts retry an even larger
45
+ // chunk → deadlock.
46
+ const chunkTokenCap = runtime.config.observerMaxChunkTokens ?? 30_000;
47
+ let effectiveCoversUpToId = coversUpToId;
48
+ if (chunkTokenCap > 0) {
49
+ let accumulated = 0;
50
+ let capIdx = chunkEntries.length;
51
+ for (let i = 0; i < chunkEntries.length; i++) {
52
+ accumulated += estimateEntryTokens(chunkEntries[i]);
53
+ if (accumulated > chunkTokenCap) {
54
+ capIdx = i;
55
+ break;
56
+ }
57
+ }
58
+ if (capIdx < chunkEntries.length) {
59
+ chunkEntries = chunkEntries.slice(0, capIdx);
60
+ effectiveCoversUpToId = chunkEntries[capIdx - 1]?.id ?? coversUpToId;
61
+ process.stderr.write(`[obs-mem] chunk capped at ~${accumulated.toLocaleString()} tokens ` +
62
+ `(${chunkEntries.length} source entries); remaining unobserved entries deferred\n`);
63
+ debugLog("observer.chunk_capped", {
64
+ originalCount: rawTailEntriesBetween(entries, coversFromId, coversUpToId).length,
65
+ cappedCount: chunkEntries.length,
66
+ cappedTokens: accumulated,
67
+ cap: chunkTokenCap,
68
+ effectiveCoversUpToId,
69
+ });
70
+ }
71
+ }
41
72
  const { text: chunk, sourceEntryIds } = serializeSourceAddressedBranchEntries(chunkEntries);
42
73
  if (!chunk.trim() || sourceEntryIds.length === 0)
43
74
  return;
@@ -83,27 +114,39 @@ export function registerObserverTrigger(pi, runtime) {
83
114
  maxTurns: turnLimits.observerMaxTurnsPerRun,
84
115
  });
85
116
  if (!records || records.length === 0) {
86
- debugLog("observer.empty", { coversFromId, coversUpToId });
117
+ debugLog("observer.empty", { coversFromId, coversUpToId: effectiveCoversUpToId });
118
+ // Advance the bound even on empty results so we don't retry the same
119
+ // chunk. Without this, returning to an old session can deadlock:
120
+ // observer runs on 159K tokens → returns empty → bound not advanced
121
+ // → next run retries an even larger chunk → repeat forever.
122
+ const placeholderData = {
123
+ records: [],
124
+ coversFromId,
125
+ coversUpToId: effectiveCoversUpToId,
126
+ tokenCount: 0,
127
+ };
128
+ pi.appendEntry(OBSERVATION_CUSTOM_TYPE, placeholderData);
129
+ debugLog("observer.empty_placeholder", { coversFromId, coversUpToId: effectiveCoversUpToId });
87
130
  if (hasUI && ui)
88
- ui.notify("Observational memory: observer returned no observations", "warning");
131
+ ui.notify("Observational memory: observer returned no observations; bound advanced to prevent re-processing", "warning");
89
132
  return;
90
133
  }
91
134
  const observationTokens = records.reduce((sum, r) => sum + estimateStringTokens(r.content), 0);
92
135
  const data = {
93
136
  records,
94
137
  coversFromId,
95
- coversUpToId,
138
+ coversUpToId: effectiveCoversUpToId,
96
139
  tokenCount: observationTokens,
97
140
  };
98
141
  debugLog("observer.records", {
99
142
  count: records.length,
100
143
  observationTokens,
101
144
  coversFromId,
102
- coversUpToId,
145
+ coversUpToId: effectiveCoversUpToId,
103
146
  records,
104
147
  });
105
148
  pi.appendEntry(OBSERVATION_CUSTOM_TYPE, data);
106
- debugLog("observer.appended", { count: records.length, tokenCount: observationTokens, coversFromId, coversUpToId });
149
+ debugLog("observer.appended", { count: records.length, tokenCount: observationTokens, coversFromId, coversUpToId: effectiveCoversUpToId });
107
150
  if (hasUI && ui)
108
151
  ui.notify(`Observational memory: ${records.length} observation${records.length === 1 ? "" : "s"} recorded (~${observationTokens.toLocaleString()} tokens)`, "info");
109
152
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@aexol/spectral",
3
- "version": "0.4.8",
3
+ "version": "0.4.9",
4
4
  "description": "Always-on coding agent for Aexol — branded pi wrapper with relay-based browser access.",
5
5
  "type": "module",
6
6
  "private": false,