@oh-my-pi/pi-agent-core 16.1.17 → 16.1.18
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 +6 -0
- package/dist/types/append-only-context.d.ts +18 -2
- package/package.json +7 -7
- package/src/append-only-context.ts +87 -43
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
|
-
*
|
|
106
|
-
*
|
|
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.
|
|
4
|
+
"version": "16.1.18",
|
|
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.
|
|
39
|
-
"@oh-my-pi/pi-catalog": "16.1.
|
|
40
|
-
"@oh-my-pi/pi-natives": "16.1.
|
|
41
|
-
"@oh-my-pi/pi-utils": "16.1.
|
|
42
|
-
"@oh-my-pi/pi-wire": "16.1.
|
|
43
|
-
"@oh-my-pi/snapcompact": "16.1.
|
|
38
|
+
"@oh-my-pi/pi-ai": "16.1.18",
|
|
39
|
+
"@oh-my-pi/pi-catalog": "16.1.18",
|
|
40
|
+
"@oh-my-pi/pi-natives": "16.1.18",
|
|
41
|
+
"@oh-my-pi/pi-utils": "16.1.18",
|
|
42
|
+
"@oh-my-pi/pi-wire": "16.1.18",
|
|
43
|
+
"@oh-my-pi/snapcompact": "16.1.18",
|
|
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
|
-
/**
|
|
166
|
-
|
|
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
|
-
*
|
|
178
|
-
*
|
|
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
|
-
//
|
|
182
|
-
|
|
183
|
-
|
|
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
|
-
//
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
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
|
-
|
|
198
|
-
for (
|
|
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.#
|
|
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.#
|
|
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.#
|
|
270
|
+
this.#messageDigests = [];
|
|
238
271
|
this.prefix.build(context, options);
|
|
239
272
|
}
|
|
240
273
|
|
|
241
|
-
/**
|
|
242
|
-
*
|
|
243
|
-
*
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
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
|
}
|