@oh-my-pi/pi-agent-core 16.1.17 → 16.1.19

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
@@ -2,6 +2,12 @@
2
2
 
3
3
  ## [Unreleased]
4
4
 
5
+ ## [16.1.18] - 2026-06-25
6
+
7
+ ### Fixed
8
+
9
+ - Fixed `AppendOnlyContextManager.syncMessages` clearing the entire log on any in-place rewrite of an already-synced message. Per-turn tool-output pruning, image stripping, or any `transformContext` re-render that touched a single message used to drop every prior turn out of the append-only log and re-send the conversation from scratch, forcing local backends (llama.cpp / Ollama / LM Studio) to re-prefill tens of thousands of tokens every few turns. `syncMessages` now finds the longest byte-stable prefix between the previously-synced messages and the new ones, truncates the log to that prefix, and only re-appends the diverged tail — so the provider's KV cache stays warm up to the divergence point. ([#3406](https://github.com/can1357/oh-my-pi/issues/3406))
10
+
5
11
  ## [16.1.17] - 2026-06-24
6
12
 
7
13
  ### Fixed
@@ -76,6 +76,10 @@ export declare class AppendOnlyLog {
76
76
  toMessages(): Message[];
77
77
  /** Direct readonly access for in-place inspection. */
78
78
  entries(): readonly Message[];
79
+ /** Drop entries past index `count`, keeping the first `count` byte-stable.
80
+ * Used by {@link AppendOnlyContextManager.syncMessages} to preserve the
81
+ * already-on-the-wire prefix when a later message diverges. */
82
+ truncate(count: number): void;
79
83
  clear(): void;
80
84
  }
81
85
  /**
@@ -102,8 +106,20 @@ export declare class AppendOnlyContextManager {
102
106
  /**
103
107
  * Sync normalized (provider-level) messages into the append-only log.
104
108
  *
105
- * Detects both compaction (shorter array) and in-place rewrites
106
- * (same length, changed content via a rolling digest).
109
+ * Three cases:
110
+ *
111
+ * 1. **Append**: same prefix, new tail → push the new entries.
112
+ * 2. **Compaction**: shorter array → clear the log and replay.
113
+ * 3. **In-place rewrite** (per-turn pruning, transformContext re-render,
114
+ * image strip, etc.): find the longest byte-stable prefix between
115
+ * the previously-synced messages and the new ones, drop the log
116
+ * down to that prefix, then append the diverged tail. Earlier
117
+ * revisions cleared the whole log on any digest change, which on
118
+ * llama.cpp / local backends forced a full ~40k-token re-prefill
119
+ * every turn that an extension, prune pass, or steering re-wrap
120
+ * rewrote a single message (#3406). Preserving the stable prefix
121
+ * lets the provider's KV cache stay warm up to the divergence
122
+ * point — the model only re-prefills from the changed message on.
107
123
  */
108
124
  syncMessages(normalizedMessages: any[]): void;
109
125
  /** Reset prefix + log for a model/provider switch while mode stays active. */
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "type": "module",
3
3
  "name": "@oh-my-pi/pi-agent-core",
4
- "version": "16.1.17",
4
+ "version": "16.1.19",
5
5
  "description": "General-purpose agent with transport abstraction, state management, and attachment support",
6
6
  "homepage": "https://omp.sh",
7
7
  "author": "Can Boluk",
@@ -35,12 +35,12 @@
35
35
  "fmt": "biome format --write ."
36
36
  },
37
37
  "dependencies": {
38
- "@oh-my-pi/pi-ai": "16.1.17",
39
- "@oh-my-pi/pi-catalog": "16.1.17",
40
- "@oh-my-pi/pi-natives": "16.1.17",
41
- "@oh-my-pi/pi-utils": "16.1.17",
42
- "@oh-my-pi/pi-wire": "16.1.17",
43
- "@oh-my-pi/snapcompact": "16.1.17",
38
+ "@oh-my-pi/pi-ai": "16.1.19",
39
+ "@oh-my-pi/pi-catalog": "16.1.19",
40
+ "@oh-my-pi/pi-natives": "16.1.19",
41
+ "@oh-my-pi/pi-utils": "16.1.19",
42
+ "@oh-my-pi/pi-wire": "16.1.19",
43
+ "@oh-my-pi/snapcompact": "16.1.19",
44
44
  "@opentelemetry/api": "^1.9.1"
45
45
  },
46
46
  "devDependencies": {
@@ -132,6 +132,15 @@ export class AppendOnlyLog {
132
132
  return this.#entries;
133
133
  }
134
134
 
135
+ /** Drop entries past index `count`, keeping the first `count` byte-stable.
136
+ * Used by {@link AppendOnlyContextManager.syncMessages} to preserve the
137
+ * already-on-the-wire prefix when a later message diverges. */
138
+ truncate(count: number): void {
139
+ if (count < 0) count = 0;
140
+ if (count >= this.#entries.length) return;
141
+ this.#entries.length = count;
142
+ }
143
+
135
144
  clear(): void {
136
145
  this.#entries = [];
137
146
  }
@@ -162,8 +171,14 @@ export class AppendOnlyContextManager {
162
171
  readonly log = new AppendOnlyLog();
163
172
  /** How many normalized messages were synced into the log as of the last sync. */
164
173
  #lastSyncCount = 0;
165
- /** Rolling digest of synced message content — detects in-place rewrites. */
166
- #syncedDigest = 0;
174
+ /**
175
+ * Per-message digests of the synced log. Lets a deep or tail rewrite
176
+ * (per-turn pruning, image strip, transformContext re-render) preserve
177
+ * the byte-stable prefix instead of re-sending the entire conversation
178
+ * — keeps the provider's prompt-cache hit rate up to the divergence
179
+ * point on every subsequent turn.
180
+ */
181
+ #messageDigests: number[] = [];
167
182
 
168
183
  build(context: AgentContext, options: BuildOptions): Context {
169
184
  this.prefix.build(context, options);
@@ -174,33 +189,51 @@ export class AppendOnlyContextManager {
174
189
  /**
175
190
  * Sync normalized (provider-level) messages into the append-only log.
176
191
  *
177
- * Detects both compaction (shorter array) and in-place rewrites
178
- * (same length, changed content via a rolling digest).
192
+ * Three cases:
193
+ *
194
+ * 1. **Append**: same prefix, new tail → push the new entries.
195
+ * 2. **Compaction**: shorter array → clear the log and replay.
196
+ * 3. **In-place rewrite** (per-turn pruning, transformContext re-render,
197
+ * image strip, etc.): find the longest byte-stable prefix between
198
+ * the previously-synced messages and the new ones, drop the log
199
+ * down to that prefix, then append the diverged tail. Earlier
200
+ * revisions cleared the whole log on any digest change, which on
201
+ * llama.cpp / local backends forced a full ~40k-token re-prefill
202
+ * every turn that an extension, prune pass, or steering re-wrap
203
+ * rewrote a single message (#3406). Preserving the stable prefix
204
+ * lets the provider's KV cache stay warm up to the divergence
205
+ * point — the model only re-prefills from the changed message on.
179
206
  */
180
207
  syncMessages(normalizedMessages: any[]): void {
181
- // Detect in-place rewrites of already-synced messages.
182
- if (
183
- this.#lastSyncCount > 0 &&
184
- this.#lastSyncCount <= normalizedMessages.length &&
185
- this.#computeDigest(normalizedMessages.slice(0, this.#lastSyncCount)) !== this.#syncedDigest
186
- ) {
208
+ // Compaction (array shrunk) every previously-synced message is gone,
209
+ // so the log can't carry any byte-stable bytes forward.
210
+ if (normalizedMessages.length < this.#lastSyncCount) {
187
211
  this.log.clear();
188
212
  this.#lastSyncCount = 0;
213
+ this.#messageDigests = [];
189
214
  }
190
215
 
191
- // Compaction array shrunk.
192
- if (normalizedMessages.length < this.#lastSyncCount) {
193
- this.log.clear();
194
- this.#lastSyncCount = 0;
216
+ // In-place rewrite: trim the log down to the longest byte-stable prefix
217
+ // that both the previous sync and the new messages share. Bound it by
218
+ // the current log length because `log.clear()` is public; direct clears
219
+ // (advisor reset) can leave the sync cursor ahead of the physical log.
220
+ // Anything past that point will be re-appended below with the new bytes.
221
+ if (this.#lastSyncCount > 0) {
222
+ const stableCount = Math.min(this.#longestStablePrefix(normalizedMessages), this.log.length);
223
+ if (stableCount < this.#lastSyncCount) {
224
+ this.log.truncate(stableCount);
225
+ this.#lastSyncCount = stableCount;
226
+ this.#messageDigests.length = stableCount;
227
+ }
195
228
  }
196
229
 
197
- const newMsgs = normalizedMessages.slice(this.#lastSyncCount);
198
- for (const msg of newMsgs) {
230
+ // Append the diverged tail (or the full delta on a normal turn).
231
+ for (let i = this.#lastSyncCount; i < normalizedMessages.length; i++) {
232
+ const msg = normalizedMessages[i];
199
233
  this.log.append(msg);
234
+ this.#messageDigests.push(this.#messageDigest(msg));
200
235
  }
201
-
202
236
  this.#lastSyncCount = normalizedMessages.length;
203
- this.#syncedDigest = this.#computeDigest(normalizedMessages);
204
237
  }
205
238
 
206
239
  /** Reset prefix + log for a model/provider switch while mode stays active. */
@@ -208,14 +241,14 @@ export class AppendOnlyContextManager {
208
241
  this.prefix.invalidate();
209
242
  this.log.clear();
210
243
  this.#lastSyncCount = 0;
211
- this.#syncedDigest = 0;
244
+ this.#messageDigests = [];
212
245
  }
213
246
 
214
247
  /** Reset the sync cursor AND clear the log. */
215
248
  resetSyncCursor(): void {
216
249
  this.log.clear();
217
250
  this.#lastSyncCount = 0;
218
- this.#syncedDigest = 0;
251
+ this.#messageDigests = [];
219
252
  }
220
253
 
221
254
  appendMessage(message: any): void {
@@ -234,34 +267,45 @@ export class AppendOnlyContextManager {
234
267
  this.prefix.invalidate();
235
268
  this.log.clear();
236
269
  this.#lastSyncCount = 0;
237
- this.#syncedDigest = 0;
270
+ this.#messageDigests = [];
238
271
  this.prefix.build(context, options);
239
272
  }
240
273
 
241
- /**
242
- * Deterministic digest over every field the provider may serialize — role,
243
- * content, tool calls (both `toolCalls` and OpenAI-wire `tool_calls`),
244
- * `tool_call_id`, `name`, `id`. Hashed with the same FNV-style rolling
245
- * accumulator so in-place rewrites of *any* of these fields are visible.
246
- */
247
- #computeDigest(messages: readonly unknown[]): number {
248
- let hash = 0;
249
- for (let i = 0; i < messages.length; i++) {
250
- const msg = messages[i];
251
- if (!msg || typeof msg !== "object") continue;
252
- const m = msg as Record<string, unknown>;
253
- const payload = JSON.stringify({
254
- r: m.role ?? null,
255
- c: m.content ?? null,
256
- tc: m.toolCalls ?? m.tool_calls ?? null,
257
- tcid: m.tool_call_id ?? null,
258
- n: m.name ?? null,
259
- id: m.id ?? null,
260
- });
261
- for (let j = 0; j < payload.length; j++) {
262
- hash = ((hash << 5) - hash + payload.charCodeAt(j)) | 0;
274
+ /** Index of the first message whose serialized bytes differ from the
275
+ * previously-synced log; equals `min(lastSyncCount, normalizedMessages.length)`
276
+ * when nothing diverged. */
277
+ #longestStablePrefix(normalizedMessages: readonly unknown[]): number {
278
+ const bound = Math.min(this.#lastSyncCount, normalizedMessages.length);
279
+ for (let i = 0; i < bound; i++) {
280
+ if (this.#messageDigest(normalizedMessages[i]) !== this.#messageDigests[i]) {
281
+ return i;
263
282
  }
264
283
  }
284
+ return bound;
285
+ }
286
+
287
+ /** Deterministic digest over every field the provider may serialize — role,
288
+ * content, provider-native replay payloads, tool calls (both `toolCalls` and
289
+ * OpenAI-wire `tool_calls`), tool-result ids/names/error flags (both internal
290
+ * camelCase and wire snake_case), and assistant `id` — so an in-place rewrite
291
+ * of *any* of these fields is visible to {@link #longestStablePrefix}. */
292
+ #messageDigest(msg: unknown): number {
293
+ if (!msg || typeof msg !== "object") return 0;
294
+ const m = msg as Record<string, unknown>;
295
+ const payload = JSON.stringify({
296
+ r: m.role ?? null,
297
+ c: m.content ?? null,
298
+ pp: m.providerPayload ?? null,
299
+ tc: m.toolCalls ?? m.tool_calls ?? null,
300
+ tcid: m.toolCallId ?? m.tool_call_id ?? null,
301
+ tn: m.toolName ?? m.name ?? null,
302
+ err: m.isError ?? null,
303
+ id: m.id ?? null,
304
+ });
305
+ let hash = 0;
306
+ for (let j = 0; j < payload.length; j++) {
307
+ hash = ((hash << 5) - hash + payload.charCodeAt(j)) | 0;
308
+ }
265
309
  return hash >>> 0;
266
310
  }
267
311
  }