dsh-lcx-codex 0.4.0-rc.8 → 0.4.0

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/ARCHITECTURE.md CHANGED
@@ -9,7 +9,7 @@ DSH 0.1.1-rc.2 `compaction-basic` defaults to a `0.8` pressure threshold and, on
9
9
  - provider-confirmed context overflow continues to use DSH's original `context-overflow` recovery unchanged;
10
10
  - manual `/compact` remains unchanged.
11
11
 
12
- The pressure wrapper is installed on the actual agent-scoped compaction instance when an agent enters `running`, because DSH's own pre-step listener dynamically dispatches `this.compactIfNeeded()` at event time. The wrapper is restored on plugin cleanup.
12
+ Agent presets may isolate `compaction` and `toolResultPruner` inside entry-local Cordis realms. DSH 0.1.1-rc.2 explicitly documents that these preset services are invisible to both the host and ordinary `agent.ctx`; host-side code must address them through `agentPresets.serviceFor(agent, name)`. LCX therefore observes agent lifecycle events globally, resolves each Agent's real preset-local compaction/pruner through that public resolver, and patches the concrete compaction instance. A root `ctx.inject(['compaction'], ...)` hook remains only for non-preset/non-isolated deployments. Concrete Cordis service identity is used for de-duplication. DSH's own pre-step listener dynamically dispatches `this.compactIfNeeded()` at event time, and the wrapper is restored on plugin cleanup.
13
13
 
14
14
  ## rc.6 search timeout coordination
15
15
 
@@ -17,12 +17,16 @@ The pressure wrapper is installed on the actual agent-scoped compaction instance
17
17
 
18
18
  # Architecture Notes — 0.4 Native Session Refactor / rc.6 Pressure Coordination
19
19
 
20
+ ## rc.11 Native cache identity
21
+
22
+ Native compaction and same-route replay reuse the active DSH/Pi conversation cache identity: the clamped session id is the `prompt_cache_key`, provider `cacheRetention` is respected, and `long` may emit `prompt_cache_retention: 24h` when supported. `cacheRetention: none` omits Native prompt-cache/session affinity. Ordinary Hosted Search remains intentionally isolated under `dsh-lcx-search:<route hash>` so search traffic cannot share the main conversation request/cache namespace.
23
+
20
24
  ## Design invariants
21
25
 
22
26
  1. **DSH owns compaction policy.** LCX never independently decides threshold, compact range, pruning, transaction boundaries or overflow retries.
23
27
  2. **Native success performs one compaction model request.** Basic summary is a failure fallback, not a parallel portable-copy generator.
24
28
  3. **DSH session log is the new checkpoint source of truth.** Opaque Native V2 state lives in `compaction/summary.rawOutput`; v3 sidecar access is legacy read-only.
25
- 4. **Opaque state never crosses an incompatible route.** Provider, model, base URL and session ancestry gate native replay.
29
+ 4. **Opaque state is same-session only.** Provider, model, base URL and exact `sourceSessionId === currentSessionId` gate Native opaque replay. Verified parent/child ancestry authorizes portable migration only; a fork never sends the parent's opaque checkpoint state.
26
30
  5. **Route migration is transparent and transient.** Reconstruct shadowed DSH messages and hand them to the normal adapter; do not persist a second portable history copy.
27
31
  6. **Ordinary search has one model tool.** `web_search` is ordinary search; `websearch_gpt_advanced` exists only for parameters absent from `WebSearchRequest`; Alpha remains its own stateful protocol.
28
32
  7. **Provider-native wire code is isolated.** Direct `/responses` SSE code is limited to Native V2 compaction/replay and Hosted Search protocol calls.
package/CHANGELOG.md CHANGED
@@ -1,5 +1,81 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.4.0 - 2026-08-23
4
+
5
+ ### Stable promotion
6
+
7
+ - Promote the fully validated `0.4.0-rc.13` runtime to the stable `0.4.0` line with no functional/runtime code changes.
8
+ - Make the rc.13 Native V2 hardening, canonical Responses replay/serialization fixes, session isolation, cache continuity, and concurrency/lifecycle fixes the recommended stable release.
9
+ - Preserve `0.3.4` only as historical stable state; `0.4.0` becomes the default npm `latest` after publication.
10
+
11
+ ### Validation
12
+
13
+ - Runtime-sensitive source is required to remain content-identical to the already live-tested rc.13 release.
14
+ - Full suite: 58/58 passed; DSH schema validation: 4/4 passed.
15
+ - Real rc.13 DSH acceptance already covers automatic Native pressure Compact, replay/cache re-warm, restart/resume, real GUI `/compact`, parent/child opaque isolation, and dual-session same-preset ServiceMutex failure cleanup.
16
+ - Stable promotion adds no new runtime behavior; publication is a version/documentation promotion only.
17
+
18
+ ## 0.4.0-rc.13 - 2026-08-23
19
+
20
+ ### Fixed
21
+
22
+ - Restrict opaque Native checkpoint replay to the exact source DSH session. Parent/child ancestry remains valid only for portable migration, so a fork never sends the parent session's opaque Native state.
23
+ - Reconcile Responses replay deltas and terminal output by stable item/call identity instead of `output_index` alone, preventing changed-index duplicate/empty text blocks and keeping terminal-only function calls balanced.
24
+ - Replace the simplified Native message/tool serializer with the public `@earendil-works/pi-ai@0.82.1` OpenAI Responses converters, preserving reasoning/message identities, tool linkage, constrained-tool semantics, deferred-tool transcript semantics, and DSH image boundaries.
25
+ - Match ordinary Pi `openai-responses` system-prompt placement: Native compact/replay now carries the developer/system prelude inside the canonical input prefix rather than introducing a separate top-level `instructions` prefix difference.
26
+ - Restore strict validation of DSH Pi replay-state envelopes before reusing native signatures; mismatched replay metadata degrades to a portable foreign-assistant projection instead of injecting stale provider-native identity.
27
+ - Treat Pi canonical role-only developer/user items as durable retained history and keep image persistence on `dsh_image_attachment` references rather than request image payloads.
28
+ - Use Pi/model or explicitly configured DSH Responses compatibility only; unknown custom routes no longer assume strict tools, grammar tools, or tool-search support.
29
+ - Mirror DSH/OpenAI Responses generation controls into Native compact/replay (`reasoning` + encrypted-reasoning include, temperature, and max output tokens), preventing the real Terra `xhigh` compact path from dropping the envelope used by ordinary Pi requests.
30
+ - Source automatic-compaction generation controls from the matching session request header rather than the Basic compaction summarizer request, so the selected conversation effort (for example Terra `xhigh`) reaches the Native provider request.
31
+ - Declare the DSH `sessions` service as an explicit Cordis runtime injection because checkpoint/replay and automatic-compaction generation parity read the live session/request header.
32
+ - Maintain a deterministic per-session request-header cache from DSH `session/event`; `compaction/start` synchronously refreshes it from the live Session before the Native request, avoiding reliance on async-context propagation for generation parity.
33
+ - Register that cross-session `session/event` observer with Cordis `{ global: true }`, matching DSH system-wide observers so Agent-carrier session events reach the standing plugin scope.
34
+ - Snapshot the exact `agentArg.session.requestHeader()` inside the Native-first pressure wrapper immediately before delegating to Basic compaction; this is the deterministic pressure-path source of generation controls, with the global session-event cache retained only as a secondary path.
35
+ - Seed the request-header cache from already-live sessions at plugin installation, seed newly announced resume/fork sessions, and evict disposed session entries so manual/non-pressure compaction remains restart-safe without retaining stale headers.
36
+ - Match the explicit Remote V2 tool-control contract used by current Codex and mature Responses compaction implementations: Native compact/replay send `tool_choice: "auto"` and `parallel_tool_calls: true` on the plain `openai-responses` route.
37
+ - Preserve only bounded provider machine diagnostics (`code`, `type`, `param`) from `response.failed`; the safe identifiers are included in the generic failure message/log so DSH compaction history can diagnose failures, while provider messages/bodies remain excluded.
38
+ - Persist Pi replay envelope v2 on successful Native replay finish so DSH retains response id/stop reason plus text/reasoning native metadata across post-compact turns.
39
+ - Preserve normal Pi OpenAI function-call identity as `call_id|item_id` for replay deltas and completed tool-call blocks, preventing loss of the provider `fc_*` item id on the next canonical request.
40
+ - Treat the session header `config` as the effective ordinary-request envelope even when DSH marks a value in `adapterDefaults`; those materialized defaults (for example Terra `xhigh` and `maxTokens`) are still sent by normal Pi and therefore must be mirrored by Native compact/replay.
41
+
42
+ ### Validation
43
+
44
+ - `tests/rc13-regressions.test.mjs`: 15/15 passed.
45
+ - Full local suite: 58/58 passed; DSH schema validation: 4/4 passed; `git diff --check` passed.
46
+ - Installed DSH/NewAPI acceptance passed on Terra xhigh: automatic 90% Native V2 compact, continuous replay/cache re-warm, DSH Web restart/resume, real GUI `/compact` plus continuation, and parent→child fork portable isolation all completed without pre-Native stock prune or cross-session opaque replay.
47
+ - This remains a local release candidate only; no npm/GitHub publication or tag is implied.
48
+
49
+ ## 0.4.0-rc.12 - 2026-08-22
50
+
51
+ ### Fixed
52
+
53
+ - Serialize every `compactIfNeeded()` call per concrete preset compaction service so same-generation sessions cannot observe each other’s temporary Native-first pruner/config state; queued calls are abortable and plugin cleanup drains active owners before restoring the original method.
54
+ - Align Native `openai-responses` session affinity with the active Pi adapter: default OpenAI-format routes use `session_id` plus `x-client-request-id`, while OpenRouter-format routes use `x-session-id`; explicit affinity headers remain authoritative.
55
+ - Restrict Basic fallback to allowlisted retryable first-checkpoint failures and fail closed once a Native/legacy checkpoint already exists.
56
+ - Require a real `response.completed` terminal event with `status=completed` for Native compaction/replay and reject orphan `function_call_output` items.
57
+ - Set credential-bearing fetches to `redirect: error` and keep provider response bodies/messages out of surfaced/logged transport errors.
58
+
59
+ ## 0.4.0-rc.11 - 2026-08-22
60
+
61
+ ### Fixed
62
+
63
+ - Restore ERR-051 cache/session affinity semantics that regressed in the rc.8 refactor: Native V2 compaction and same-route replay now use the DSH/Pi conversation session id as the clamped `prompt_cache_key` instead of a route fingerprint, inherit the active provider `cacheRetention`, emit `prompt_cache_retention: 24h` only for supported `long` retention, and omit Native cache affinity when retention is `none`.
64
+ - Keep ordinary Hosted Search on its intentionally separate `dsh-lcx-search:<route hash>` namespace.
65
+
66
+ ## 0.4.0-rc.10 - 2026-08-22
67
+
68
+ ### Fixed
69
+
70
+ - Fix Native-first pressure coordination for DSH 0.1.1-rc.2 Agent presets by using the public `agentPresets.serviceFor(agent, name)` resolver for preset-local `compaction` and `toolResultPruner` instances. This replaces the rc.9 assumption that ordinary `agent.ctx` lookup could see entry-local isolated services.
71
+
72
+ ## 0.4.0-rc.9 - 2026-08-22
73
+
74
+ ### Fixed
75
+
76
+ - Restore the Alpha capability/ref-store interfaces consumed by `lib/index.js`; the rc.8 release package could pass its unit tests but fail immediately at module import with missing `AlphaCapabilityStore` / `AlphaRefStore` exports.
77
+ - Add package-entry import regression coverage so CI fails when the server entry point and internal module exports drift out of sync.
78
+ - Restore Native-first pressure coordination for isolated agent presets using DSH 0.1.1-rc.2's public `agentPresets.serviceFor(agent, name)` resolver. Preset-local `compaction` / `toolResultPruner` services are not visible through ordinary host or `agent.ctx` lookup; the plugin now addresses the actual per-Agent instances before applying the 90% Native / 95% emergency policy. A root service-lifecycle hook remains for non-preset deployments.
3
79
  ## 0.4.0-rc.8
4
80
 
5
81
  - Rebase the plugin on DSH `0.1.1-rc.2`; older DSH releases are no longer a supported runtime target.
package/README.md CHANGED
@@ -209,7 +209,7 @@ dsh plugin --profile web add dsh-lcx-codex@next
209
209
 
210
210
  ```powershell
211
211
  dsh plugin --profile web remove dsh-lcx-codex
212
- dsh plugin --profile web add .\dsh-lcx-codex-0.4.0-rc.8.tgz
212
+ dsh plugin --profile web add .\dsh-lcx-codex-0.4.0.tgz
213
213
  dsh web
214
214
  ```
215
215
 
package/lib/compact-v2.js CHANGED
@@ -5,6 +5,10 @@ export const REMOTE_COMPACTION_V2_FEATURE = 'remote_compaction_v2'
5
5
 
6
6
  function isObject(value) { return value !== null && typeof value === 'object' && !Array.isArray(value) }
7
7
  function fail(message, code = 'LCX_COMPACT_INVALID_RESPONSE') { const e = new Error(message); e.code = code; return e }
8
+ function safeMachineField(value) {
9
+ if (typeof value !== 'string' || value.length === 0 || value.length > 96) return undefined
10
+ return /^[A-Za-z0-9_.:\[\]-]+$/u.test(value) ? value : undefined
11
+ }
8
12
 
9
13
  export function mergeFeatureHeader(headers = {}) {
10
14
  const result = { ...headers }
@@ -15,7 +19,24 @@ export function mergeFeatureHeader(headers = {}) {
15
19
  return result
16
20
  }
17
21
 
18
- export function buildNativeCompactionBody({ model, input, instructions, tools, promptCacheKey }) {
22
+ export function responsesGenerationEnvelope({ reasoningEffort, temperature, maxTokens } = {}) {
23
+ const result = {}
24
+ if (reasoningEffort !== undefined && reasoningEffort !== 'off') {
25
+ result.reasoning = { effort: String(reasoningEffort), summary: 'auto' }
26
+ result.include = ['reasoning.encrypted_content']
27
+ }
28
+ if (temperature !== undefined) {
29
+ if (!Number.isFinite(temperature)) throw fail('Responses temperature must be finite', 'LCX_COMPACT_INVALID_INPUT')
30
+ result.temperature = Number(temperature)
31
+ }
32
+ if (maxTokens !== undefined) {
33
+ if (!Number.isSafeInteger(maxTokens) || maxTokens <= 0) throw fail('Responses maxTokens must be a positive safe integer', 'LCX_COMPACT_INVALID_INPUT')
34
+ result.max_output_tokens = Math.max(16, maxTokens)
35
+ }
36
+ return result
37
+ }
38
+
39
+ export function buildNativeCompactionBody({ model, input, instructions, tools, promptCacheKey, promptCacheRetention, reasoningEffort, temperature, maxTokens }) {
19
40
  if (!Array.isArray(input)) throw fail('native compaction input must be an array', 'LCX_COMPACT_INVALID_INPUT')
20
41
  if (input.some((item) => item?.type === 'compaction_trigger')) throw fail('native compaction input already contains compaction_trigger', 'LCX_COMPACT_DUPLICATE_TRIGGER')
21
42
  const nativeTools = responsesTools(tools)
@@ -24,9 +45,13 @@ export function buildNativeCompactionBody({ model, input, instructions, tools, p
24
45
  input: [...structuredClone(input), { type: 'compaction_trigger' }],
25
46
  stream: true,
26
47
  store: false,
48
+ tool_choice: 'auto',
49
+ parallel_tool_calls: true,
27
50
  ...(instructions !== undefined ? { instructions } : {}),
28
51
  ...(nativeTools !== undefined ? { tools: nativeTools } : {}),
29
52
  ...(promptCacheKey ? { prompt_cache_key: String(promptCacheKey) } : {}),
53
+ ...(promptCacheRetention ? { prompt_cache_retention: String(promptCacheRetention) } : {}),
54
+ ...responsesGenerationEnvelope({ reasoningEffort, temperature, maxTokens }),
30
55
  }
31
56
  }
32
57
 
@@ -67,6 +92,7 @@ function validateOutput(output) {
67
92
  }
68
93
  }
69
94
  for (const id of calls) if (!results.has(id)) throw fail(`native compaction output has orphan function_call ${id}`)
95
+ for (const id of results) if (!calls.has(id)) throw fail(`native compaction output has orphan function_call_output ${id}`)
70
96
  return { output: structuredClone(output), compaction: structuredClone(compact) }
71
97
  }
72
98
 
@@ -89,14 +115,27 @@ export async function parseNativeCompactionSse(response, options = {}) {
89
115
  await consumeSse(response, (event) => {
90
116
  if (!isObject(event)) return
91
117
  if (event.type === 'error' || event.type === 'response.failed' || event.type === 'response.incomplete') {
92
- const e = fail(event.error?.message ?? event.response?.error?.message ?? `native compaction ended with ${event.type}`, 'LCX_COMPACT_UPSTREAM_ERROR')
93
- e.status = event.error?.status
118
+ const upstream = event.type === 'response.failed' ? event.response?.error : event.error
119
+ const providerCode = safeMachineField(upstream?.code)
120
+ const providerType = safeMachineField(upstream?.type)
121
+ const providerParam = safeMachineField(upstream?.param)
122
+ const diagnostics = [
123
+ providerCode ? `providerCode=${providerCode}` : '',
124
+ providerType ? `providerType=${providerType}` : '',
125
+ providerParam ? `providerParam=${providerParam}` : '',
126
+ ].filter(Boolean).join(' ')
127
+ const e = fail(`native compaction ended with ${event.type}${diagnostics ? ` ${diagnostics}` : ''}`, 'LCX_COMPACT_UPSTREAM_ERROR')
128
+ if (Number.isInteger(upstream?.status ?? event.error?.status)) e.status = upstream?.status ?? event.error?.status
129
+ if (providerCode) e.providerCode = providerCode
130
+ if (providerType) e.providerType = providerType
131
+ if (providerParam) e.providerParam = providerParam
94
132
  throw e
95
133
  }
96
134
  if (event.type === 'response.output_item.added' || event.type === 'response.output_item.done') mergeItem(event)
97
- if (event.type === 'response.completed' || event.type === 'response.done') terminal = event.response
135
+ if (event.type === 'response.completed') terminal = event.response
98
136
  }, options)
99
137
  if (!isObject(terminal)) throw fail('native compaction stream ended without response.completed', 'LCX_COMPACT_INCOMPLETE_SSE')
138
+ if (terminal.status !== 'completed') throw fail('native compaction response.completed did not carry completed status', 'LCX_COMPACT_INCOMPLETE_SSE')
100
139
  const eventItems = [...new Set([...byIndex.values(), ...byId.values()])]
101
140
  const output = Array.isArray(terminal.output) && terminal.output.length > 0 ? terminal.output : eventItems
102
141
  const normalized = validateOutput(output)
@@ -108,8 +147,8 @@ export async function parseNativeCompactionSse(response, options = {}) {
108
147
  }
109
148
  }
110
149
 
111
- export async function requestNativeCompaction({ baseURL, model, input, instructions, tools, promptCacheKey, idempotencyKey, headers, signal, timeoutMs, maxAttempts, maxResponseBytes }) {
112
- const body = buildNativeCompactionBody({ model, input, instructions, tools, promptCacheKey })
150
+ export async function requestNativeCompaction({ baseURL, model, input, instructions, tools, promptCacheKey, promptCacheRetention, reasoningEffort, temperature, maxTokens, idempotencyKey, headers, signal, timeoutMs, maxAttempts, maxResponseBytes }) {
151
+ const body = buildNativeCompactionBody({ model, input, instructions, tools, promptCacheKey, promptCacheRetention, reasoningEffort, temperature, maxTokens })
113
152
  const requestHeaders = mergeFeatureHeader({ ...headers, ...(idempotencyKey ? { 'idempotency-key': String(idempotencyKey) } : {}) })
114
153
  return fetchSseWithRetry(`${String(baseURL).replace(/\/+$/u, '')}/responses`, body, requestHeaders, signal, timeoutMs, {
115
154
  maxAttempts,
@@ -1,4 +1,7 @@
1
1
  import { offloadRequestImagesWithPolicy } from '@deepseek-ai/dsh-llm'
2
+ import { convertResponsesMessages, convertResponsesTools } from '@earendil-works/pi-ai/api/openai-responses-shared'
3
+ import { createGrammarToolInputProperties } from '@earendil-works/pi-ai/api/constrained-sampling'
4
+ import { getBuiltinModels } from '@earendil-works/pi-ai/providers/all'
2
5
  const DEFAULT_MAX_REQUEST_IMAGE_BYTES = 20 * 1024 * 1024
3
6
  const DEFAULT_REQUEST_IMAGE_PIXEL_BUDGET = 2048 * 2048
4
7
  const DEFAULT_REQUEST_IMAGE_MAX_BYTES = 1024 * 1024
@@ -12,14 +15,6 @@ function error(message, code) {
12
15
  return value
13
16
  }
14
17
 
15
- function textOf(blocks) {
16
- return (Array.isArray(blocks) ? blocks : []).filter((b) => b?.type === 'text' && typeof b.text === 'string').map((b) => b.text).join('')
17
- }
18
-
19
- function hasImage(blocks) {
20
- return (Array.isArray(blocks) ? blocks : []).some((block) => block?.type === 'image' || (block?.type === 'tool-result' && hasImage(block.content)))
21
- }
22
-
23
18
  export async function resolveModelImageSupport(ctx, route, signal) {
24
19
  const llm = ctx?.get?.('llm') ?? ctx?.llm
25
20
  if (typeof llm?.resolveModelInfo !== 'function') return 'unknown'
@@ -58,111 +53,195 @@ async function imagePart(block, ctx, options, imageMap) {
58
53
  if (encodedBytes > options.maxRequestImageBytes) throw error('one image exceeds the configured LCX request image bound', 'LCX_COMPACT_IMAGE_TOO_LARGE')
59
54
  const imageUrl = `data:${image.mediaType};base64,${image.data.toString('base64')}`
60
55
  imageMap.set(imageUrl, structuredClone(image.ref))
61
- return { type: 'input_image', image_url: imageUrl }
62
- }
63
-
64
- async function toolResultOutput(block, ctx, options, imageMap) {
65
- if (!hasImage(block?.content)) return textOf(block?.content) || '(no output)'
66
- const parts = []
67
- let pending = ''
68
- const flush = () => { if (pending) parts.push({ type: 'input_text', text: pending }); pending = '' }
69
- for (const part of block.content ?? []) {
70
- if (part?.type === 'text') pending += part.text
71
- else if (part?.type === 'image') { flush(); parts.push(await imagePart(part, ctx, options, imageMap)) }
72
- else if (part?.type === 'tool-result') {
73
- const nested = await toolResultOutput(part, ctx, options, imageMap)
74
- if (typeof nested === 'string') pending += nested === '(no output)' ? '' : nested
75
- else { flush(); parts.push(...nested) }
76
- }
56
+ return { type: 'input_image', detail: 'auto', image_url: imageUrl }
57
+ }
58
+
59
+ async function piImagePart(block, ctx, options, imageMap) {
60
+ if (options.imageSupport === 'unsupported') return { type: 'text', text: '[image omitted because the target model does not support image input]' }
61
+ const image = await attachmentResolver(ctx, options)(block, options.signal)
62
+ const encodedBytes = Math.ceil(image.data.byteLength / 3) * 4
63
+ if (encodedBytes > options.maxRequestImageBytes) throw error('one image exceeds the configured LCX request image bound', 'LCX_COMPACT_IMAGE_TOO_LARGE')
64
+ const data = image.data.toString('base64')
65
+ imageMap.set(`data:${image.mediaType};base64,${data}`, structuredClone(image.ref))
66
+ return { type: 'image', data, mimeType: image.mediaType }
67
+ }
68
+
69
+ function parseArguments(value) {
70
+ if (typeof value !== 'string') return value && typeof value === 'object' ? structuredClone(value) : {}
71
+ try {
72
+ const parsed = JSON.parse(value)
73
+ return parsed && typeof parsed === 'object' && !Array.isArray(parsed) ? parsed : {}
74
+ } catch { return {} }
75
+ }
76
+
77
+ function emptyUsage() {
78
+ return { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, totalTokens: 0, cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 } }
79
+ }
80
+
81
+ function invalidReplay(message) { return error(`invalid pi-ai replay state: ${message}`, 'LCX_COMPACT_INVALID_REPLAY_STATE') }
82
+ function unsupportedContent(type) { return error(`LCX Compact cannot safely serialize DSH message content type: ${String(type)}`, 'LCX_CHECKPOINT_PORTABLE_UNSUPPORTED_CONTENT') }
83
+ function replayBlockType(type) { if (type === 'text') return 'text'; if (type === 'reasoning') return 'reasoning'; if (type === 'tool-call') return 'tool-call'; return undefined }
84
+
85
+ export function readDshPiReplayState(value) {
86
+ if (!isObject(value)) throw invalidReplay('expected a replay envelope')
87
+ const response = value.response
88
+ if (!isObject(response)) throw invalidReplay('expected a response object')
89
+ if (response.kind !== 'pi-ai') throw invalidReplay('unknown state kind')
90
+ if (response.version !== 2) throw invalidReplay(`unsupported version ${String(response.version)}`)
91
+ for (const key of ['api', 'provider', 'model']) if (typeof response[key] !== 'string' || response[key].length === 0) throw invalidReplay(`${key} must be a non-empty string`)
92
+ if (!['stop', 'length', 'toolUse', 'error', 'aborted'].includes(response.stopReason)) throw invalidReplay('unknown stopReason')
93
+ if (response.responseModel !== undefined && typeof response.responseModel !== 'string') throw invalidReplay('responseModel must be a string')
94
+ if (response.responseId !== undefined && typeof response.responseId !== 'string') throw invalidReplay('responseId must be a string')
95
+ if (!Array.isArray(value.blocks)) throw invalidReplay('blocks must be an array')
96
+ for (const [index, block] of value.blocks.entries()) {
97
+ if (!isObject(block)) throw invalidReplay(`block ${index} must be an object`)
98
+ if (!['text', 'reasoning', 'tool-call'].includes(block.type)) throw invalidReplay(`block ${index} has an unknown type`)
99
+ for (const signature of ['textSignature', 'thinkingSignature', 'thoughtSignature']) if (block[signature] !== undefined && typeof block[signature] !== 'string') throw invalidReplay(`block ${index} ${signature} must be a string`)
100
+ if (block.redacted !== undefined && typeof block.redacted !== 'boolean') throw invalidReplay(`block ${index} redacted must be boolean`)
77
101
  }
78
- flush()
79
- return parts.some((part) => part.type === 'input_image') ? parts : parts.map((part) => part.text ?? '').join('') || '(no output)'
80
- }
81
-
82
- async function messageItems(message, ctx, options, imageMap) {
83
- const blocks = Array.isArray(message?.content) ? message.content : []
84
- const items = []
85
- if (message?.role === 'assistant') {
86
- const text = textOf(blocks)
87
- if (text) items.push({ type: 'message', role: 'assistant', content: [{ type: 'output_text', text }] })
88
- for (const call of blocks.filter((block) => block?.type === 'tool-call')) {
89
- items.push({
90
- type: 'function_call',
91
- call_id: String(call.id),
92
- name: String(call.name),
93
- arguments: typeof call.arguments === 'string' ? call.arguments : JSON.stringify(call.arguments ?? {}),
94
- })
95
- }
96
- return items
102
+ return { response, blocks: value.blocks }
103
+ }
104
+
105
+ function foreignAssistant(message) {
106
+ const source = message?.source?.kind === 'model' ? message.source : undefined
107
+ const content = []
108
+ for (const block of message?.content ?? []) {
109
+ if (block?.type === 'text') content.push({ type: 'text', text: String(block.text ?? '') })
110
+ else if (block?.type === 'reasoning') content.push({ type: 'thinking', thinking: String(block.text ?? '') })
111
+ else if (block?.type === 'tool-call') content.push({ type: 'toolCall', id: String(block.id), name: String(block.name), arguments: parseArguments(block.arguments) })
112
+ else if (block?.type === 'image') throw unsupportedContent('assistant image')
113
+ else throw unsupportedContent(block?.type)
97
114
  }
98
- if (message?.role !== 'user') return items
99
-
100
- const ordinary = blocks.filter((block) => block?.type === 'text' || block?.type === 'image')
101
- if (ordinary.length > 0) {
102
- const content = []
103
- let pending = ''
104
- const flush = () => { if (pending) content.push({ type: 'input_text', text: pending }); pending = '' }
105
- for (const block of ordinary) {
106
- if (block.type === 'text') pending += block.text
107
- else { flush(); content.push(await imagePart(block, ctx, options, imageMap)) }
115
+ return { role: 'assistant', content, api: 'dsh-foreign', provider: source?.provider ?? 'dsh-foreign', model: source?.model ?? 'dsh-foreign', usage: emptyUsage(), stopReason: content.some((block) => block.type === 'toolCall') ? 'toolUse' : 'stop', timestamp: 0 }
116
+ }
117
+
118
+ function replayedAssistant(message, source) {
119
+ const state = readDshPiReplayState(source.replayState)
120
+ if (state.response.provider !== source.provider) throw invalidReplay('provider does not match assistant source')
121
+ if (state.response.model !== source.model) throw invalidReplay('model does not match assistant source')
122
+ if (state.blocks.length !== (message?.content ?? []).length) throw invalidReplay('block count does not match assistant content')
123
+ const content = (message.content ?? []).map((block, index) => {
124
+ const replay = state.blocks[index]
125
+ if (replayBlockType(block?.type) !== replay?.type) throw invalidReplay(`block ${index} does not match assistant content`)
126
+ if (block.type === 'text') return { type: 'text', text: String(block.text ?? ''), ...(replay.textSignature === undefined ? {} : { textSignature: replay.textSignature }) }
127
+ if (block.type === 'reasoning') return { type: 'thinking', thinking: String(block.text ?? ''), ...(replay.thinkingSignature === undefined ? {} : { thinkingSignature: replay.thinkingSignature }), ...(replay.redacted === undefined ? {} : { redacted: replay.redacted }) }
128
+ return { type: 'toolCall', id: String(block.id), name: String(block.name), arguments: parseArguments(block.arguments), ...(replay.thoughtSignature === undefined ? {} : { thoughtSignature: replay.thoughtSignature }) }
129
+ })
130
+ return { role: 'assistant', content, api: state.response.api, provider: state.response.provider, model: state.response.model, ...(state.response.responseModel === undefined ? {} : { responseModel: state.response.responseModel }), ...(state.response.responseId === undefined ? {} : { responseId: state.response.responseId }), usage: emptyUsage(), stopReason: state.response.stopReason, timestamp: 0 }
131
+ }
132
+
133
+ function toPiAssistant(message, onReplayDegrade) {
134
+ const source = message?.source
135
+ if (source?.kind !== 'model' || source.replayState === undefined) return foreignAssistant(message)
136
+ try { return replayedAssistant(message, source) }
137
+ catch (cause) {
138
+ if (cause?.code !== 'LCX_COMPACT_INVALID_REPLAY_STATE') throw cause
139
+ onReplayDegrade?.(cause.message)
140
+ return foreignAssistant(message)
141
+ }
142
+ }
143
+
144
+ async function piToolContent(blocks, ctx, options, imageMap) {
145
+ const content = []
146
+ for (const block of blocks ?? []) {
147
+ if (block?.type === 'text') content.push({ type: 'text', text: String(block.text ?? '') })
148
+ else if (block?.type === 'image') content.push(await piImagePart(block, ctx, options, imageMap))
149
+ else if (block?.type === 'tool-result') content.push(...await piToolContent(block.content, ctx, options, imageMap))
150
+ }
151
+ return content.length > 0 ? content : [{ type: 'text', text: '(no output)' }]
152
+ }
153
+
154
+ async function dshToPiMessages(messages, ctx, options, imageMap) {
155
+ const result = []
156
+ for (const message of messages ?? []) {
157
+ if (message?.role === 'system') continue
158
+ if (message?.role === 'assistant') { result.push(toPiAssistant(message, options.onReplayDegrade)); continue }
159
+ if (message?.role !== 'user') continue
160
+ const ordinary = (message.content ?? []).filter((block) => block?.type === 'text' || block?.type === 'image')
161
+ if (ordinary.length > 0) {
162
+ const content = []
163
+ for (const block of ordinary) content.push(block.type === 'text' ? { type: 'text', text: String(block.text ?? '') } : await piImagePart(block, ctx, options, imageMap))
164
+ if (content.length > 0) result.push({ role: 'user', content, timestamp: 0 })
108
165
  }
109
- flush()
110
- if (content.length > 0) items.push({ type: 'message', role: 'user', content })
166
+ for (const block of (message.content ?? []).filter((value) => value?.type === 'tool-result')) result.push({ role: 'toolResult', toolCallId: String(block.toolCallId), toolName: String(block.toolName ?? block.name ?? 'unknown'), content: await piToolContent(block.content, ctx, options, imageMap), addedToolNames: block.addedToolNames ?? message.addedToolNames ?? [], isError: block.isError === true, timestamp: 0 })
111
167
  }
112
- for (const result of blocks.filter((block) => block?.type === 'tool-result')) {
113
- items.push({
114
- type: 'function_call_output',
115
- call_id: String(result.toolCallId),
116
- output: await toolResultOutput(result, ctx, options, imageMap),
117
- })
168
+ return result
169
+ }
170
+
171
+ function builtinResponsesModel(provider, modelId) {
172
+ try { return getBuiltinModels(String(provider ?? '')).find((model) => model?.id === modelId && model?.api === 'openai-responses') }
173
+ catch { return undefined }
174
+ }
175
+
176
+ function piModel(options) {
177
+ const route = options.route ?? {}
178
+ const explicit = options.model && typeof options.model === 'object' ? options.model : undefined
179
+ const provider = String(explicit?.provider ?? route.provider ?? 'dsh-lcx-codex')
180
+ const id = String(explicit?.id ?? route.model ?? 'unknown')
181
+ const builtin = builtinResponsesModel(provider, id)
182
+ const compat = { ...(builtin?.compat ?? {}), ...(options.responsesCompat ?? {}), ...(explicit?.compat ?? {}) }
183
+ const input = options.imageSupport === 'supported' ? ['text', 'image'] : options.imageSupport === 'unsupported' ? ['text'] : (explicit?.input ?? builtin?.input ?? ['text'])
184
+ return {
185
+ ...(builtin ?? {}), ...(explicit ?? {}), id, name: String(explicit?.name ?? builtin?.name ?? id), api: 'openai-responses', provider,
186
+ baseUrl: String(explicit?.baseUrl ?? route.baseURL ?? builtin?.baseUrl ?? ''), reasoning: typeof explicit?.reasoning === 'boolean' ? explicit.reasoning : (builtin?.reasoning ?? true), input,
187
+ cost: explicit?.cost ?? builtin?.cost ?? { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }, contextWindow: Number(explicit?.contextWindow ?? builtin?.contextWindow) > 0 ? Number(explicit?.contextWindow ?? builtin?.contextWindow) : 262144,
188
+ maxTokens: Number(explicit?.maxTokens ?? builtin?.maxTokens) > 0 ? Number(explicit?.maxTokens ?? builtin?.maxTokens) : 32768,
189
+ ...(Object.keys(compat).length > 0 ? { compat } : {}),
190
+ }
191
+ }
192
+
193
+ function splitDeferredTools(context, enabled) {
194
+ const unique = new Map()
195
+ for (const tool of context.tools ?? []) if (tool?.name) unique.set(tool.name, tool)
196
+ if (!enabled) return { immediate: [...unique.values()], deferred: new Map() }
197
+ const deferredNames = new Set(); const usedNames = new Set()
198
+ for (const message of context.messages ?? []) {
199
+ if (message.role === 'assistant') {
200
+ for (const block of message.content ?? []) if (block.type === 'toolCall') usedNames.add(block.name)
201
+ } else if (message.role === 'toolResult') {
202
+ for (const name of message.addedToolNames ?? []) if (!usedNames.has(name)) deferredNames.add(name)
203
+ }
118
204
  }
119
- return items
205
+ const immediate = []; const deferred = new Map()
206
+ for (const [name, tool] of unique) { if (deferredNames.has(name)) deferred.set(name, tool); else immediate.push(tool) }
207
+ return { immediate, deferred }
120
208
  }
121
209
 
122
210
  export async function serializeDshMessages(messages, ctx, options = {}) {
123
211
  const imageMap = new Map()
124
- const input = []
125
212
  const normalized = {
126
- imageSupport: options.imageSupport ?? 'unknown',
127
- signal: options.signal,
128
- maxRequestImageBytes: Number.isSafeInteger(options.maxRequestImageBytes) && options.maxRequestImageBytes > 0
129
- ? options.maxRequestImageBytes
130
- : DEFAULT_MAX_REQUEST_IMAGE_BYTES,
131
- requestImagePixelBudget: Number.isSafeInteger(options.requestImagePixelBudget) && options.requestImagePixelBudget > 0
132
- ? options.requestImagePixelBudget
133
- : DEFAULT_REQUEST_IMAGE_PIXEL_BUDGET,
134
- requestImageMaxBytes: Number.isSafeInteger(options.requestImageMaxBytes) && options.requestImageMaxBytes > 0
135
- ? options.requestImageMaxBytes
136
- : DEFAULT_REQUEST_IMAGE_MAX_BYTES,
213
+ imageSupport: options.imageSupport ?? 'unknown', signal: options.signal, route: options.route ?? {}, model: options.model, responsesCompat: options.responsesCompat,
214
+ systemPrompt: typeof options.systemPrompt === 'string' ? options.systemPrompt : undefined, includeSystemPrompt: options.includeSystemPrompt === true, onReplayDegrade: options.onReplayDegrade,
215
+ maxRequestImageBytes: Number.isSafeInteger(options.maxRequestImageBytes) && options.maxRequestImageBytes > 0 ? options.maxRequestImageBytes : DEFAULT_MAX_REQUEST_IMAGE_BYTES,
216
+ requestImagePixelBudget: Number.isSafeInteger(options.requestImagePixelBudget) && options.requestImagePixelBudget > 0 ? options.requestImagePixelBudget : DEFAULT_REQUEST_IMAGE_PIXEL_BUDGET,
217
+ requestImageMaxBytes: Number.isSafeInteger(options.requestImageMaxBytes) && options.requestImageMaxBytes > 0 ? options.requestImageMaxBytes : DEFAULT_REQUEST_IMAGE_MAX_BYTES,
137
218
  }
138
- const projected = offloadRequestImagesWithPolicy(messages ?? [], {
139
- representation: 'base64',
140
- maxBytes: normalized.maxRequestImageBytes,
141
- byteQuantum: 1,
142
- byteLength: ref => Math.min(ref.bytes, normalized.requestImageMaxBytes),
219
+ const projected = offloadRequestImagesWithPolicy(messages ?? [], { representation: 'base64', maxBytes: normalized.maxRequestImageBytes, byteQuantum: 1, byteLength: ref => Math.min(ref.bytes, normalized.requestImageMaxBytes) })
220
+ const model = piModel(normalized)
221
+ const context = { systemPrompt: normalized.systemPrompt, messages: await dshToPiMessages(projected, ctx, normalized, imageMap), tools: options.tools ?? [] }
222
+ const supportsStrictMode = model.compat?.supportsStrictMode ?? false
223
+ const supportsOpenAIGrammarTools = model.compat?.supportsOpenAIGrammarTools ?? false
224
+ const supportsToolSearch = model.compat?.supportsToolSearch ?? false
225
+ const grammarToolInputProperties = createGrammarToolInputProperties(context.tools, supportsOpenAIGrammarTools)
226
+ const placement = splitDeferredTools(context, supportsToolSearch)
227
+ const toolOptions = { supportsStrictMode, supportsOpenAIGrammarTools }
228
+ const input = convertResponsesMessages(model, context, new Set(['openai', 'openai-codex', 'opencode']), {
229
+ includeSystemPrompt: normalized.includeSystemPrompt, grammarToolInputProperties, deferredTools: placement.deferred, toolOptions,
143
230
  })
144
- for (const message of projected) {
145
- if (message?.role === 'system') continue
146
- input.push(...await messageItems(message, ctx, normalized, imageMap))
147
- }
148
- return { input, imageMap }
231
+ const tools = options.tools === undefined ? undefined : convertResponsesTools(placement.immediate, toolOptions)
232
+ return { input, imageMap, tools }
149
233
  }
150
234
 
151
235
  export function responsesTools(tools) {
152
236
  if (tools === undefined) return undefined
153
237
  if (!Array.isArray(tools)) throw error('LCX Responses tools must be an array', 'LCX_COMPACT_INVALID_TOOLS')
154
- return tools.map((tool) => {
155
- if (!isObject(tool) || typeof tool.name !== 'string' || !tool.name) throw error('LCX Responses tool has no name', 'LCX_COMPACT_INVALID_TOOLS')
156
- return {
157
- type: 'function',
158
- name: tool.name,
159
- description: typeof tool.description === 'string' ? tool.description : '',
160
- parameters: isObject(tool.parameters) ? structuredClone(tool.parameters) : { type: 'object', properties: {} },
161
- strict: false,
162
- }
163
- })
238
+ if (tools.every((tool) => isObject(tool) && typeof tool.type === 'string')) return structuredClone(tools)
239
+ for (const tool of tools) if (!isObject(tool) || typeof tool.name !== 'string' || !tool.name) throw error('LCX Responses tool has no name', 'LCX_COMPACT_INVALID_TOOLS')
240
+ return convertResponsesTools(tools, { supportsStrictMode: false, supportsOpenAIGrammarTools: false })
164
241
  }
165
242
 
243
+ function isResponsesMessageItem(item) { return isObject(item) && typeof item.role === 'string' && (item.type === undefined || item.type === 'message') }
244
+
166
245
  function mapImageParts(value, mapper) {
167
246
  if (!Array.isArray(value)) return value
168
247
  return value.map((part) => {
@@ -181,7 +260,7 @@ export function persistNativeImageReferences(output, imageMap) {
181
260
  return { type: 'dsh_image_attachment', attachment: structuredClone(ref) }
182
261
  }
183
262
  return (output ?? []).map((item) => {
184
- if (item?.type === 'message' && Array.isArray(item.content)) return { ...structuredClone(item), content: mapImageParts(item.content, persist) }
263
+ if (isResponsesMessageItem(item) && Array.isArray(item.content)) return { ...structuredClone(item), content: mapImageParts(item.content, persist) }
185
264
  if (item?.type === 'function_call_output' && Array.isArray(item.output)) return { ...structuredClone(item), output: mapImageParts(item.output, persist) }
186
265
  return structuredClone(item)
187
266
  })
@@ -206,7 +285,7 @@ export async function hydrateNativeImageReferences(output, ctx, options = {}) {
206
285
  }
207
286
  const result = []
208
287
  for (const item of output ?? []) {
209
- if (item?.type === 'message' && Array.isArray(item.content)) {
288
+ if (isResponsesMessageItem(item) && Array.isArray(item.content)) {
210
289
  const content = []
211
290
  for (const part of item.content) content.push(await hydratePart(part, ctx, normalized))
212
291
  result.push({ ...structuredClone(item), content })