@nijaru/pi-provider-compaction 0.0.6 → 0.0.7

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/README.md CHANGED
@@ -1,6 +1,6 @@
1
1
  # pi-provider-compaction
2
2
 
3
- Experimental provider-native compaction for [Pi](https://github.com/earendil-works/pi), with a meaningful portable summary saved alongside each native checkpoint. Minimum runtime: Pi 0.87.0. Development checks target Pi 1.2.0.
3
+ Experimental provider-native compaction for [Pi](https://github.com/earendil-works/pi), with a meaningful portable summary saved alongside each native checkpoint. Minimum runtime: Pi 0.87.0. Development checks target Pi 1.3.0.
4
4
 
5
5
  **This experimental design is not production-validated. Do not enable it globally yet.** Real runner/adapter fixtures and a Codex live smoke test pass; broader backend and deployment validation remains incomplete.
6
6
 
@@ -73,7 +73,7 @@ pi --extension ./dist/index.js
73
73
 
74
74
  The manifest loads the built `dist/index.js`, including a bundled stateless Responses serializer validator; source or host dependency changes require a rebuild. `bun run check` rebuilds first and tests the artifact against Pi's actual host serializer, including function-call history replayed as grammar tools. Keep global installation disabled while testing. Publishing and global activation are separate approval steps.
75
75
 
76
- Tests include real Pi 1.2.0 runner/adapter fixtures for all three protocols, repeated prefix compaction, transformed suffix preservation, unchanged fallback, grammar tools, strict defaults, tool IDs, images and prompt updates. Set `PI_COMPACTOR_FIXTURE_PATH` to a `pi-compactor` entrypoint to check SDK flag precedence in both extension orders. The optional installed-fast-mode fixture (`PI_FAST_MODE_FIXTURE_PATH`) checks actual `pi-fast-mode` composition across `/reload`; it creates an isolated fast-mode configuration.
76
+ Tests include real Pi 1.3.0 runner/adapter fixtures for all three protocols, repeated prefix compaction, transformed suffix preservation, unchanged fallback, grammar tools, strict defaults, tool IDs, images, prompt updates, cross-model thinking and mid-conversation effort changes. Set `PI_COMPACTOR_FIXTURE_PATH` to a `pi-compactor` entrypoint to check SDK flag precedence in both extension orders. The optional installed-fast-mode fixture (`PI_FAST_MODE_FIXTURE_PATH`) checks actual `pi-fast-mode` composition across `/reload`; it creates an isolated fast-mode configuration.
77
77
 
78
78
  Authenticated protocol smoke tests are separate and send three small requests:
79
79
 
package/dist/index.js CHANGED
@@ -637,19 +637,13 @@ function transformMessages(messages, model, normalizeToolCallId) {
637
637
  const isSameModel = assistantMsg.provider === model.provider && assistantMsg.api === model.api && assistantMsg.model === model.id;
638
638
  const transformedContent = assistantMsg.content.flatMap((block) => {
639
639
  if (block.type === "thinking") {
640
- if (block.redacted) {
641
- return isSameModel ? block : [];
642
- }
643
- if (isSameModel && block.thinkingSignature)
640
+ if (!isSameModel)
641
+ return [];
642
+ if (block.redacted || block.thinkingSignature)
644
643
  return block;
645
644
  if (!block.thinking || block.thinking.trim() === "")
646
645
  return [];
647
- if (isSameModel)
648
- return block;
649
- return {
650
- type: "text",
651
- text: block.thinking
652
- };
646
+ return block;
653
647
  }
654
648
  if (block.type === "text") {
655
649
  if (isSameModel)
@@ -840,6 +834,12 @@ function convertResponsesMessages(model, context, allowedToolCallProviders, opti
840
834
  });
841
835
  };
842
836
  const includeInitialSystemMessage = options?.includeSystemPrompt ?? true;
837
+ const pushConfigurationUpdate = (effort) => {
838
+ messages.push({
839
+ type: "configuration_update",
840
+ reasoning: { effort }
841
+ });
842
+ };
843
843
  const compat = model.compat;
844
844
  const instructionRole = model.reasoning && compat?.supportsDeveloperRole !== false ? "developer" : "system";
845
845
  let msgIndex = 0;
@@ -946,6 +946,9 @@ function convertResponsesMessages(model, context, allowedToolCallProviders, opti
946
946
  }
947
947
  if (output.length === 0)
948
948
  continue;
949
+ if (options?.configurationUpdateEffort !== undefined && isSameModel && msg.providerThinkingLevel !== undefined) {
950
+ pushConfigurationUpdate(msg.providerThinkingLevel);
951
+ }
949
952
  messages.push(...output);
950
953
  } else if (msg.role === "toolResult") {
951
954
  const [callId] = msg.toolCallId.split("|");
@@ -967,6 +970,8 @@ function convertResponsesMessages(model, context, allowedToolCallProviders, opti
967
970
  if (!isLeadingSystemMessage)
968
971
  msgIndex++;
969
972
  }
973
+ if (options?.configurationUpdateEffort !== undefined)
974
+ pushConfigurationUpdate(options.configurationUpdateEffort);
970
975
  return messages;
971
976
  }
972
977
  function convertResponsesTools(tools, options) {
@@ -1463,7 +1468,7 @@ var SNAPSHOT_TTL_MS = 5 * 60000;
1463
1468
  function equal(left, right) {
1464
1469
  return JSON.stringify(left) === JSON.stringify(right);
1465
1470
  }
1466
- function serializeInput(model, context, declarations = context) {
1471
+ function serializeInput(model, context, declarations = context, configurationUpdateEffort) {
1467
1472
  const compat = model.compat;
1468
1473
  const flag = (name) => compat?.[name] === true;
1469
1474
  const codex = model.api === "openai-codex-responses";
@@ -1471,6 +1476,7 @@ function serializeInput(model, context, declarations = context) {
1471
1476
  const tools = resolveTranscript2(declarations, flag("supportsMidConvoSystemMessages"));
1472
1477
  return convertResponsesMessages(model, normalized, model.api === "azure-openai-responses" ? AZURE_TOOL_CALL_PROVIDERS : TOOL_CALL_PROVIDERS, {
1473
1478
  includeSystemPrompt: !codex,
1479
+ configurationUpdateEffort,
1474
1480
  grammarToolInputProperties: createGrammarToolInputProperties(getDeclaredTools(tools.messages), flag("supportsOpenAIGrammarTools")),
1475
1481
  supportsMidConvoSystemMessages: flag("supportsMidConvoSystemMessages"),
1476
1482
  supportsAdditionalTools: flag("supportsAdditionalTools"),
@@ -1495,12 +1501,15 @@ function capturePreparedRequest(model, context, canonical, payload, reject = ()
1495
1501
  reject("snapshot exceeds size limit");
1496
1502
  return;
1497
1503
  }
1498
- const expected = serializeInput(model, context);
1504
+ const last = payload.input.at(-1);
1505
+ const compat = model.compat;
1506
+ const configurationUpdateEffort = model.api === "openai-responses" && compat?.supportsMidConvoEffort === true && last?.type === "configuration_update" && isObject(last.reasoning) && typeof last.reasoning.effort === "string" ? last.reasoning.effort : undefined;
1507
+ const expected = serializeInput(model, context, context, configurationUpdateEffort);
1499
1508
  if (!equal(expected, payload.input)) {
1500
1509
  reject("prepared input differs from serializer");
1501
1510
  return;
1502
1511
  }
1503
- return { context: structuredClone(context), canonical: structuredClone(canonical), payload: structuredClone(payload), portableInput: expected, createdAt: Date.now() };
1512
+ return { context: structuredClone(context), canonical: structuredClone(canonical), payload: structuredClone(payload), portableInput: expected, configurationUpdateEffort, createdAt: Date.now() };
1504
1513
  }
1505
1514
  function promptCount(input) {
1506
1515
  let count = 0;
@@ -1508,6 +1517,10 @@ function promptCount(input) {
1508
1517
  count++;
1509
1518
  return count;
1510
1519
  }
1520
+ function serializePrefix(model, snapshot, prefix) {
1521
+ const input = serializeInput(model, prefix, snapshot.context, snapshot.configurationUpdateEffort);
1522
+ return snapshot.configurationUpdateEffort === undefined ? input : input.slice(0, -1);
1523
+ }
1511
1524
  function closedTools(context) {
1512
1525
  const pending = new Set;
1513
1526
  for (const message of context.messages) {
@@ -1551,7 +1564,7 @@ function selectCoveredPrefix(model, snapshot, prefix, reject = () => {}) {
1551
1564
  reject("projected prefix differs from request context");
1552
1565
  return;
1553
1566
  }
1554
- const expected = serializeInput(model, mapped, snapshot.context);
1567
+ const expected = serializePrefix(model, snapshot, mapped);
1555
1568
  if (!equal(snapshot.portableInput.slice(0, expected.length), expected)) {
1556
1569
  reject("serialized prefix does not match prepared input");
1557
1570
  return;
@@ -1584,7 +1597,7 @@ function summarySlot(model, snapshot, summaryPrefix) {
1584
1597
  const mapped = mappedPrefix(snapshot, summaryPrefix);
1585
1598
  if (!mapped)
1586
1599
  return;
1587
- const prefix = serializeInput(model, mapped, snapshot.context);
1600
+ const prefix = serializePrefix(model, snapshot, mapped);
1588
1601
  const index = promptCount(prefix);
1589
1602
  if (prefix.length !== index + 1 || !equal(snapshot.portableInput.slice(0, prefix.length), prefix))
1590
1603
  return;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@nijaru/pi-provider-compaction",
3
- "version": "0.0.6",
3
+ "version": "0.0.7",
4
4
  "type": "module",
5
5
  "description": "Provider-native context compaction adapters for Pi",
6
6
  "keywords": [
@@ -44,9 +44,9 @@
44
44
  "LICENSE"
45
45
  ],
46
46
  "devDependencies": {
47
- "@earendil-works/pi-ai": "1.2.0",
48
- "@earendil-works/pi-agent-core": "1.2.0",
49
- "@earendil-works/pi-coding-agent": "1.2.0",
47
+ "@earendil-works/pi-ai": "1.3.0",
48
+ "@earendil-works/pi-agent-core": "1.3.0",
49
+ "@earendil-works/pi-coding-agent": "1.3.0",
50
50
  "@types/bun": "latest",
51
51
  "oxlint": "^1.81.0",
52
52
  "typescript": "^6.0.3"
package/replay.ts CHANGED
@@ -14,7 +14,7 @@ export function equal(left: unknown, right: unknown): boolean {
14
14
  }
15
15
 
16
16
  /** Validator only. No serialized item from this function is transmitted. */
17
- export function serializeInput(model: Model<any>, context: TranscriptContext, declarations = context): ResponseItem[] {
17
+ export function serializeInput(model: Model<any>, context: TranscriptContext, declarations = context, configurationUpdateEffort?: string): ResponseItem[] {
18
18
  const compat = model.compat as Record<string, unknown> | undefined;
19
19
  const flag = (name: string) => compat?.[name] === true;
20
20
  const codex = model.api === "openai-codex-responses";
@@ -22,6 +22,7 @@ export function serializeInput(model: Model<any>, context: TranscriptContext, de
22
22
  const tools = resolveTranscript(declarations, flag("supportsMidConvoSystemMessages"));
23
23
  return convertResponsesMessages(model, normalized, model.api === "azure-openai-responses" ? AZURE_TOOL_CALL_PROVIDERS : TOOL_CALL_PROVIDERS, {
24
24
  includeSystemPrompt: !codex,
25
+ configurationUpdateEffort,
25
26
  grammarToolInputProperties: createGrammarToolInputProperties(getDeclaredTools(tools.messages), flag("supportsOpenAIGrammarTools")),
26
27
  supportsMidConvoSystemMessages: flag("supportsMidConvoSystemMessages"),
27
28
  supportsAdditionalTools: flag("supportsAdditionalTools"),
@@ -40,6 +41,8 @@ export interface PreparedRequest {
40
41
  payload: JsonObject & { input: ResponseItem[] };
41
42
  /** The portable wire input before our sole summary substitution. */
42
43
  portableInput: ResponseItem[];
44
+ /** The final request-only effort update; historical efforts come from the transcript. */
45
+ configurationUpdateEffort?: string;
43
46
  replacement?: { index: number; count: number };
44
47
  createdAt: number;
45
48
  }
@@ -52,9 +55,16 @@ export function capturePreparedRequest(
52
55
  // Reject stateful provider requests: coverage must be the complete visible input.
53
56
  if (payload.previous_response_id || payload.conversation) { reject("stateful request"); return; }
54
57
  if (Buffer.byteLength(JSON.stringify({ context, payload })) > MAX_SNAPSHOT_BYTES) { reject("snapshot exceeds size limit"); return; }
55
- const expected = serializeInput(model, context);
58
+ // Read the actual prepared request's mode, not provider/auth guesses. Exact conversion
59
+ // below still validates every historical update, its position and its complete shape.
60
+ const last = payload.input.at(-1);
61
+ const compat = model.compat as Record<string, unknown> | undefined;
62
+ const configurationUpdateEffort = model.api === "openai-responses" && compat?.supportsMidConvoEffort === true &&
63
+ last?.type === "configuration_update" && isObject(last.reasoning) && typeof last.reasoning.effort === "string"
64
+ ? last.reasoning.effort : undefined;
65
+ const expected = serializeInput(model, context, context, configurationUpdateEffort);
56
66
  if (!equal(expected, payload.input)) { reject("prepared input differs from serializer"); return; }
57
- return { context: structuredClone(context), canonical: structuredClone(canonical), payload: structuredClone(payload) as PreparedRequest["payload"], portableInput: expected, createdAt: Date.now() };
67
+ return { context: structuredClone(context), canonical: structuredClone(canonical), payload: structuredClone(payload) as PreparedRequest["payload"], portableInput: expected, configurationUpdateEffort, createdAt: Date.now() };
58
68
  }
59
69
 
60
70
  function promptCount(input: readonly ResponseItem[]): number {
@@ -63,6 +73,13 @@ function promptCount(input: readonly ResponseItem[]): number {
63
73
  return count;
64
74
  }
65
75
 
76
+ function serializePrefix(model: Model<any>, snapshot: PreparedRequest, prefix: TranscriptContext): ResponseItem[] {
77
+ const input = serializeInput(model, prefix, snapshot.context, snapshot.configurationUpdateEffort);
78
+ // The converter appends the next reply's effort even to a partial transcript.
79
+ // It is not historical coverage and must remain in the live suffix on replay.
80
+ return snapshot.configurationUpdateEffort === undefined ? input : input.slice(0, -1);
81
+ }
82
+
66
83
  /** No open/synthetic tool exchange may cross the coverage boundary. */
67
84
  function closedTools(context: TranscriptContext): boolean {
68
85
  const pending = new Set<string>();
@@ -94,7 +111,7 @@ export function selectCoveredPrefix(model: Model<any>, snapshot: PreparedRequest
94
111
  if (!closedTools(prefix)) { reject("prefix has an incomplete tool exchange or failed assistant"); return; }
95
112
  const mapped = mappedPrefix(snapshot, prefix);
96
113
  if (!mapped) { reject("projected prefix differs from request context"); return; }
97
- const expected = serializeInput(model, mapped, snapshot.context);
114
+ const expected = serializePrefix(model, snapshot, mapped);
98
115
  if (!equal(snapshot.portableInput.slice(0, expected.length), expected)) { reject("serialized prefix does not match prepared input"); return; }
99
116
  const start = promptCount(expected);
100
117
  let end = expected.length;
@@ -121,7 +138,7 @@ export function substituteSummary(
121
138
  export function summarySlot(model: Model<any>, snapshot: PreparedRequest, summaryPrefix: TranscriptContext): number | undefined {
122
139
  const mapped = mappedPrefix(snapshot, summaryPrefix);
123
140
  if (!mapped) return;
124
- const prefix = serializeInput(model, mapped, snapshot.context);
141
+ const prefix = serializePrefix(model, snapshot, mapped);
125
142
  const index = promptCount(prefix);
126
143
  if (prefix.length !== index + 1 || !equal(snapshot.portableInput.slice(0, prefix.length), prefix)) return;
127
144
  return index;