@oh-my-pi/pi-ai 18.2.5 → 18.2.6

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,13 @@
2
2
 
3
3
  ## [Unreleased]
4
4
 
5
+ ## [18.2.6] - 2026-09-18
6
+
7
+ ### Fixed
8
+
9
+ - Fixed Anthropic prompt-cache head re-baselining on every memory recall refresh: the system breakpoint now anchors on the last stable segment instead of the volatile recall suffix, and the stable-system fingerprint ignores recall blocks, so a recall refresh re-bills only the suffix instead of the whole tools+system head.
10
+ - Fixed auth-broker client config resolution failing silently on Windows when reading the token file or `config.yml`; reads now use `node:fs` instead of `Bun.file`.
11
+
5
12
  ## [18.2.5] - 2026-09-17
6
13
 
7
14
  ### Added
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@oh-my-pi/pi-ai",
3
- "version": "18.2.5",
3
+ "version": "18.2.6",
4
4
  "description": "Unified LLM API with automatic model discovery and provider configuration",
5
5
  "keywords": [
6
6
  "ai",
@@ -128,11 +128,11 @@
128
128
  "fmt": "oxfmt --no-error-on-unmatched-pattern 'src/**/*.{ts,tsx}' '{test,bench,examples,scripts}/**/*.ts' '*.ts'"
129
129
  },
130
130
  "dependencies": {
131
- "@oh-my-pi/omptype": "18.2.5",
132
- "@oh-my-pi/pi-catalog": "18.2.5",
133
- "@oh-my-pi/pi-natives": "18.2.5",
134
- "@oh-my-pi/pi-utils": "18.2.5",
135
- "@oh-my-pi/pi-wire": "18.2.5"
131
+ "@oh-my-pi/omptype": "18.2.6",
132
+ "@oh-my-pi/pi-catalog": "18.2.6",
133
+ "@oh-my-pi/pi-natives": "18.2.6",
134
+ "@oh-my-pi/pi-utils": "18.2.6",
135
+ "@oh-my-pi/pi-wire": "18.2.6"
136
136
  },
137
137
  "devDependencies": {
138
138
  "@types/bun": "^1.3.14"
@@ -4,6 +4,7 @@
4
4
  * token file → local SQLite) in one place so build-time tooling sees the same
5
5
  * credentials as the TUI.
6
6
  */
7
+ import * as fs from "node:fs/promises";
7
8
  import * as path from "node:path";
8
9
  import {
9
10
  $envExact,
@@ -60,7 +61,7 @@ async function defaultResolveConfigValue(config: string): Promise<string | undef
60
61
 
61
62
  async function readTokenFile(): Promise<string | null> {
62
63
  try {
63
- const raw = await Bun.file(getAuthBrokerTokenFilePath()).text();
64
+ const raw = await fs.readFile(getAuthBrokerTokenFilePath(), "utf8");
64
65
  const trimmed = raw.trim();
65
66
  return trimmed.length > 0 ? trimmed : null;
66
67
  } catch (err) {
@@ -99,7 +100,7 @@ async function readConfigYaml(agentDir: string): Promise<ConfigSnapshot> {
99
100
  for (const filename of MAIN_CONFIG_FILENAMES) {
100
101
  const configPath = path.join(agentDir, filename);
101
102
  try {
102
- const raw = await Bun.file(configPath).text();
103
+ const raw = await fs.readFile(configPath, "utf8");
103
104
  const parsed = YAML.parse(raw);
104
105
  if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) return {};
105
106
  const record = parsed as Record<string, unknown>;
@@ -121,7 +122,8 @@ export async function loadAuthBrokerAccountPool(): Promise<AuthBrokerAccountPool
121
122
 
122
123
  let parsed: unknown;
123
124
  try {
124
- parsed = await Bun.file(filePath).json();
125
+ const raw = await fs.readFile(filePath, "utf8");
126
+ parsed = JSON.parse(raw.charCodeAt(0) === 0xfeff ? raw.slice(1) : raw);
125
127
  } catch (error) {
126
128
  throw new AIError.ConfigurationError(`Unable to read OMP_AUTH_BROKER_ACCOUNT_POOL_FILE at ${filePath}`, {
127
129
  cause: error,
@@ -3954,8 +3954,17 @@ function applyPromptCaching(params: MessageCreateParamsStreaming, cacheControl?:
3954
3954
  const messageEnd = hasTrailingAssistantPad ? trailingIndex - 1 : trailingIndex;
3955
3955
 
3956
3956
  // A breakpoint caches every preceding byte, not only the decorated message.
3957
- // Once per-call or turn-scoped content appears, no later message can anchor a
3958
- // prefix reusable by the next request.
3957
+ // A per-call or turn-scoped message is rebuilt next request, so a prefix
3958
+ // spanning it cannot match — but only at its own position. Messages after
3959
+ // the mark are ordinary persisted history with stable bytes, so a later
3960
+ // breakpoint still matches everything after the mark. The cost is bounded
3961
+ // to re-billing the marked bytes themselves, not the growing tail.
3962
+ // Hence two anchors, not a truncation: the newest candidate at or before
3963
+ // the first per-call/turn-scoped message (when one exists) pins the
3964
+ // reusable prefix behind the mark, and the rolling tail candidates pin
3965
+ // the suffix after it. Turn-scoped `clear_at` messages are absent next
3966
+ // request, so they still truncate the decimation range (ordinals would
3967
+ // shift), but per-call marks no longer freeze the tail.
3959
3968
  let stableMessageEnd = messageEnd;
3960
3969
  for (let index = 0; index <= messageEnd; index++) {
3961
3970
  const message = params.messages[index];
@@ -3980,17 +3989,21 @@ function applyPromptCaching(params: MessageCreateParamsStreaming, cacheControl?:
3980
3989
  // Stable historical decimation checkpoint every 15 user turns (15th, 30th, 45th...)
3981
3990
  const decimationIndices = userIndices.filter((_, ordinal) => (ordinal + 1) % ANTHROPIC_DECIMATION_INTERVAL === 0);
3982
3991
 
3983
- // Collect up to 2 trailing candidates from the reusable prefix, skipping
3984
- // mid-conversation tool-control messages. They contain only tool_addition /
3985
- // tool_removal blocks, so cache_control is always rejected there; parking
3986
- // the rolling window on one spends the tail breakpoint on a decoration
3987
- // that always fails, and with decimation checkpoints present the remaining
3988
- // breakpoints land on already-cached history while the growing tail is
3989
- // re-billed as uncached input every turn.
3992
+ // Collect up to 2 trailing candidates from the message tail, skipping
3993
+ // per-call messages, turn-scoped messages, and mid-conversation
3994
+ // tool-control messages. A per-call tail candidate is rebuilt next request
3995
+ // (fresh timestamps on appended probes, fresh redaction bytes), so a
3996
+ // breakpoint on it cannot match — it would spend the tail anchor on bytes
3997
+ // that never repeat while the persisted history behind it goes uncached.
3998
+ // Turn-scoped messages are absent next request for the same reason, and
3999
+ // tool controls reject cache_control outright. The walk starts at the
4000
+ // message tail (not the truncated prefix end) so the anchor advances every
4001
+ // turn; the sub-prefix candidate below covers the reusable region behind
4002
+ // a mark.
3990
4003
  const trailingCandidates: number[] = [];
3991
- for (let index = stableMessageEnd; index >= 0 && trailingCandidates.length < 2; index--) {
4004
+ for (let index = messageEnd; index >= 0 && trailingCandidates.length < 2; index--) {
3992
4005
  const message = params.messages[index];
3993
- if (!message) continue;
4006
+ if (!message || message.clear_at === "next_user_message" || isPerCallContextMessage(message)) continue;
3994
4007
  if (
3995
4008
  message.role === "system" &&
3996
4009
  typeof message.content !== "string" &&
@@ -4002,11 +4015,13 @@ function applyPromptCaching(params: MessageCreateParamsStreaming, cacheControl?:
4002
4015
  }
4003
4016
  trailingCandidates.push(index);
4004
4017
  }
4005
-
4006
4018
  // Prioritize:
4007
4019
  // 1. Most recent trailing message
4008
4020
  // 2. Latest decimation checkpoints (newest first) to maintain stable long-context anchors
4009
- // 3. Second trailing message
4021
+ // 3. Newest message at or before the first per-call/turn-scoped mark, so a
4022
+ // volatile interior message costs only its own re-billed bytes instead
4023
+ // of invalidating the whole reusable prefix behind it
4024
+ // 4. Second trailing message
4010
4025
  const candidateIndices: number[] = [];
4011
4026
  if (trailingCandidates.length > 0) {
4012
4027
  candidateIndices.push(trailingCandidates[0]);
@@ -4016,6 +4031,9 @@ function applyPromptCaching(params: MessageCreateParamsStreaming, cacheControl?:
4016
4031
  candidateIndices.push(decimationIndices[i]);
4017
4032
  }
4018
4033
  }
4034
+ if (stableMessageEnd < messageEnd && stableMessageEnd >= 0 && !candidateIndices.includes(stableMessageEnd)) {
4035
+ candidateIndices.push(stableMessageEnd);
4036
+ }
4019
4037
  for (const index of trailingCandidates) {
4020
4038
  if (!candidateIndices.includes(index)) {
4021
4039
  candidateIndices.push(index);
@@ -4034,17 +4052,57 @@ function applyPromptCaching(params: MessageCreateParamsStreaming, cacheControl?:
4034
4052
  }
4035
4053
  }
4036
4054
 
4055
+ /**
4056
+ * Trailing system-prompt segments carrying per-turn volatile content (memory
4057
+ * recall blocks). They are rendered by the coding agent as their own
4058
+ * `systemPrompt` array elements and appended last, so on the wire they
4059
+ * normally form a volatile suffix after the stable prefix. The system cache
4060
+ * breakpoint anchors on the last stable segment instead of the array tail, so
4061
+ * a recall refresh re-bills only the suffix and the message tail for one turn
4062
+ * while the tools+stable-system prefix stays a cache hit. The fingerprint in
4063
+ * `planStableAnthropicSystem` is scoped the same way, so a recall-only change
4064
+ * no longer resets the tool/control baselines either.
4065
+ *
4066
+ * Only a genuinely trailing volatile run counts: a `before_agent_start`
4067
+ * extension override may append a stable policy block after the staged recall
4068
+ * block, and that block must stay fingerprinted stable (a change to it has to
4069
+ * re-baseline). A volatile block stranded mid-array still poisons the prefix
4070
+ * at its position — prefix caching is positional, so no classification can
4071
+ * save the bytes after it — but the stable tail is at least fingerprinted
4072
+ * instead of silently excluded.
4073
+ *
4074
+ * Detection is by our own markup, not model identity: recall blocks always
4075
+ * open with `<memories>`. Stable segments containing recalled text elsewhere
4076
+ * (e.g. quoted in conversation) are unaffected — only a leading tag counts.
4077
+ */
4078
+ const VOLATILE_SYSTEM_SEGMENT_MARKERS = ["<memories>"];
4079
+
4080
+ function stableSystemSuffixStart(systemBlocks: readonly AnthropicSystemBlock[]): number {
4081
+ let start = systemBlocks.length;
4082
+ while (start > 0) {
4083
+ const text = systemBlocks[start - 1]?.text ?? "";
4084
+ if (!VOLATILE_SYSTEM_SEGMENT_MARKERS.some(marker => text.startsWith(marker))) break;
4085
+ start--;
4086
+ }
4087
+ return start;
4088
+ }
4089
+
4037
4090
  /**
4038
4091
  * Anchor cache_control on the stable request head — the last (non-deferred)
4039
- * tool definition and the last system block. The canonical cache order is
4040
- * tools → system → messages, so a breakpoint on the final system block caches
4041
- * the entire tools+system prefix, and the extra tool breakpoint keeps the tool
4092
+ * tool definition and the last stable system block. The canonical cache order is
4093
+ * tools → system → messages, so a breakpoint on the final stable system block caches
4094
+ * the entire tools+stable-system prefix, and the extra tool breakpoint keeps the tool
4042
4095
  * definitions cached even when the system text changes. This guarantees the
4043
4096
  * large, unchanging head is a cache hit on every turn regardless of how the
4044
4097
  * message tail churns — the breakpoint placement first-party Anthropic clients
4045
4098
  * (Claude Code, Pi) use. Without it, the general API-key path anchors only the
4046
4099
  * moving message tail, so tail churn re-writes the whole head uncached.
4047
4100
  *
4101
+ * Volatile trailing segments (memory recall) sit after the breakpoint, so a
4102
+ * recall refresh re-bills only the suffix and the tail for one turn instead of
4103
+ * the whole head. When every system block is volatile there is no stable
4104
+ * boundary and the breakpoint stays on the array tail (previous behavior).
4105
+ *
4048
4106
  * Anthropic allows at most 4 cache breakpoints per request. At most one is
4049
4107
  * spent on tools and one on system here, leaving the remaining budget for
4050
4108
  * the message tail and historical decimation checkpoints in `applyPromptCaching`.
@@ -4080,9 +4138,28 @@ function applyHeadCaching(
4080
4138
  }
4081
4139
  }
4082
4140
 
4083
- if (systemBlocks && systemBlocks.length > 0 && !systemBlocks.some(block => block.cache_control != null)) {
4084
- const lastBlock = systemBlocks[systemBlocks.length - 1];
4085
- if (lastBlock) lastBlock.cache_control = cloneAnthropicCacheControl(cacheControl);
4141
+ if (systemBlocks && systemBlocks.length > 0) {
4142
+ // Anchor on the last stable block so a volatile recall suffix refresh
4143
+ // re-bills only the suffix, not the whole head. The skip-if-decorated
4144
+ // check applies only when there is no volatile suffix (previous
4145
+ // behavior): with a suffix present the boundary anchor is added
4146
+ // whenever the anchor block itself lacks a breakpoint, even if the
4147
+ // OAuth path pre-decorated its identity block — otherwise the only
4148
+ // system breakpoint sits before the stable prompt and a recall
4149
+ // refresh re-bills it. The message budget in `applyPromptCaching`
4150
+ // shrinks accordingly (4 minus head breakpoints). All-volatile falls
4151
+ // back to tail anchoring (previous behavior).
4152
+ const suffixStart = stableSystemSuffixStart(systemBlocks);
4153
+ if (suffixStart === systemBlocks.length) {
4154
+ if (!systemBlocks.some(block => block.cache_control != null)) {
4155
+ const lastBlock = systemBlocks[systemBlocks.length - 1];
4156
+ if (lastBlock) lastBlock.cache_control = cloneAnthropicCacheControl(cacheControl);
4157
+ }
4158
+ } else {
4159
+ const anchorIndex = suffixStart === 0 ? systemBlocks.length - 1 : suffixStart - 1;
4160
+ const anchor = systemBlocks[anchorIndex];
4161
+ if (anchor && anchor.cache_control == null) anchor.cache_control = cloneAnthropicCacheControl(cacheControl);
4162
+ }
4086
4163
  }
4087
4164
  }
4088
4165
 
@@ -4166,11 +4243,15 @@ function getAnthropicControlState(
4166
4243
  ): AnthropicControlState | undefined {
4167
4244
  if (!state) return undefined;
4168
4245
  const root = messages[0];
4246
+ // Key on the stable system prefix, not the full array: a volatile recall
4247
+ // suffix refresh must resolve the same baseline or the declared-tool,
4248
+ // effort, and control-transition state it preserves is lost with it.
4249
+ const stablePrefix = system?.slice(0, stableSystemSuffixStart(system)) ?? null;
4169
4250
  const fingerprint = String(
4170
4251
  Bun.hash(
4171
4252
  JSON.stringify([
4172
4253
  sessionId ?? "",
4173
- system?.map(block => block.text) ?? null,
4254
+ stablePrefix?.map(block => block.text) ?? null,
4174
4255
  root ? anthropicControlMessageProjection(root) : null,
4175
4256
  ]),
4176
4257
  ),
@@ -4216,14 +4297,16 @@ function syncAnthropicControlState(state: AnthropicControlState, messages: reado
4216
4297
  }
4217
4298
 
4218
4299
  /**
4219
- * Keep the top-level `system` array byte-stable across a session. The blocks
4220
- * captured on the first request are replayed verbatim (with the current
4221
- * request's cache breakpoints) while their text is unchanged. A text change
4300
+ * Keep the top-level `system` array byte-stable across a session. The stable
4301
+ * prefix captured on the first request is replayed verbatim (with the current
4302
+ * request's cache breakpoints) while its text is unchanged; the volatile
4303
+ * recall suffix always passes through current-turn. A stable-prefix change
4222
4304
  * re-baselines instead of duplicating the prompt as a mid-conversation
4223
4305
  * system message: omp's system prompt is one rendered segment that embeds
4224
4306
  * the tool roster, so replaying a second copy on every later request would
4225
4307
  * cost the full prompt again per change. The prefix rewrite is absorbed by
4226
- * `prefix_mismatch_behavior: "drop_block"` and one cache miss.
4308
+ * `prefix_mismatch_behavior: "drop_block"` and one cache miss, while a
4309
+ * recall-only change keeps the tool/control baselines intact.
4227
4310
  */
4228
4311
  function planStableAnthropicSystem(
4229
4312
  current: AnthropicSystemBlock[] | undefined,
@@ -4231,16 +4314,21 @@ function planStableAnthropicSystem(
4231
4314
  enabled: boolean,
4232
4315
  ): AnthropicSystemBlock[] | undefined {
4233
4316
  if (!state || !enabled) return current;
4234
- const fingerprint = JSON.stringify(current?.map(block => block.text) ?? null);
4317
+ const suffixStart = stableSystemSuffixStart(current ?? []);
4318
+ const fingerprint = JSON.stringify(current?.slice(0, suffixStart).map(block => block.text) ?? null);
4235
4319
  if (state.systemFingerprint !== fingerprint) {
4236
4320
  resetAnthropicControlState(state);
4237
4321
  state.systemFingerprint = fingerprint;
4238
- state.stableSystemBlocks = current?.map(block => ({ type: block.type, text: block.text }));
4239
- }
4240
- return state.stableSystemBlocks?.map((block, index) => {
4241
- const cacheControl = current?.[index]?.cache_control;
4242
- return cacheControl ? { ...block, cache_control: cloneAnthropicCacheControl(cacheControl) } : { ...block };
4243
- });
4322
+ state.stableSystemBlocks = current?.slice(0, suffixStart).map(block => ({ type: block.type, text: block.text }));
4323
+ }
4324
+ const stableReplay =
4325
+ state.stableSystemBlocks?.map((block, index) => {
4326
+ const cacheControl = current?.[index]?.cache_control;
4327
+ return cacheControl ? { ...block, cache_control: cloneAnthropicCacheControl(cacheControl) } : { ...block };
4328
+ }) ?? [];
4329
+ const suffix = current?.slice(suffixStart).map(block => ({ ...block })) ?? [];
4330
+ const replayed = [...stableReplay, ...suffix];
4331
+ return replayed.length > 0 ? replayed : undefined;
4244
4332
  }
4245
4333
 
4246
4334
  function anthropicToolDefinitionKey(tool: AnthropicWireTool): string {