mixdog 0.9.97 → 0.9.100

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.
Files changed (49) hide show
  1. package/package.json +9 -2
  2. package/scripts/dependency-lock-cache-key.mjs +30 -0
  3. package/src/rules/agent/30-explorer.md +43 -32
  4. package/src/runtime/agent/orchestrator/agent-trace.mjs +19 -5
  5. package/src/runtime/agent/orchestrator/providers/grok-oauth.mjs +7 -0
  6. package/src/runtime/agent/orchestrator/providers/media-normalization.mjs +30 -3
  7. package/src/runtime/agent/orchestrator/providers/model-catalog.mjs +5 -3
  8. package/src/runtime/agent/orchestrator/providers/provider-catalog-cache.mjs +23 -1
  9. package/src/runtime/agent/orchestrator/session/manager/ask-session.mjs +2 -1
  10. package/src/runtime/agent/orchestrator/session/manager/pending-messages.mjs +44 -6
  11. package/src/runtime/agent/orchestrator/session/manager/prompt-utils.mjs +2 -1
  12. package/src/runtime/agent/orchestrator/session/store/serialize.mjs +3 -6
  13. package/src/runtime/agent/orchestrator/tools/builtin/read-image-resize.mjs +103 -12
  14. package/src/runtime/agent/orchestrator/tools/builtin/read-special-files.mjs +10 -30
  15. package/src/runtime/agent/orchestrator/tools/patch/orchestrator.mjs +27 -84
  16. package/src/runtime/attachments/pdf-extract.mjs +83 -0
  17. package/src/runtime/attachments/store.mjs +515 -0
  18. package/src/runtime/attachments/store.test.mjs +188 -0
  19. package/src/runtime/channels/lib/interaction-handlers.mjs +1 -191
  20. package/src/runtime/channels/lib/runtime-paths.mjs +1 -4
  21. package/src/runtime/channels/lib/worker-ipc.mjs +0 -66
  22. package/src/runtime/channels/lib/worker-main.mjs +1 -20
  23. package/src/runtime/memory/lib/http-wire.mjs +39 -4
  24. package/src/runtime/shared/json-metrics.mjs +94 -0
  25. package/src/runtime/shared/turn-snapshot.mjs +13 -0
  26. package/src/runtime/shared/turn-worktree-snapshot.mjs +19 -0
  27. package/src/session-runtime/provider-models.mjs +11 -1
  28. package/src/session-runtime/quick-model-rows.mjs +38 -7
  29. package/src/session-runtime/quick-search-models.mjs +15 -17
  30. package/src/session-runtime/runtime-core.mjs +9 -4
  31. package/src/session-runtime/runtime-tunables.mjs +9 -6
  32. package/src/session-runtime/session-title.mjs +27 -3
  33. package/src/standalone/channel-transport.mjs +28 -12
  34. package/src/standalone/daemon.mjs +3 -0
  35. package/src/standalone/session-protocol.mjs +1 -0
  36. package/src/standalone/session-service.mjs +23 -1
  37. package/src/standalone/session-transport.mjs +3 -2
  38. package/src/tui/app/prompt-submit.mjs +7 -9
  39. package/src/tui/app/use-prompt-handlers.mjs +7 -3
  40. package/src/tui/dist/index.mjs +249 -43
  41. package/src/tui/paste-attachments.mjs +13 -7
  42. package/src/tui/prompt-history-store.mjs +52 -4
  43. package/src/tui/session/agent-job-feed.mjs +5 -1
  44. package/src/tui/session/live-share.mjs +21 -7
  45. package/src/tui/session/queue-helpers.mjs +6 -6
  46. package/src/tui/session/session-api-ext.mjs +12 -4
  47. package/src/tui/session/session-api.mjs +10 -1
  48. package/src/tui/session/session-flow.mjs +4 -2
  49. package/src/tui/session-local.mjs +15 -5
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "mixdog",
3
- "version": "0.9.97",
3
+ "version": "0.9.100",
4
4
  "private": false,
5
5
  "type": "module",
6
6
  "description": "Standalone mixdog coding-agent CLI/TUI workspace.",
@@ -88,13 +88,18 @@
88
88
  "test:embedding-runtime": "node --test scripts/embedding-runtime-prune-test.mjs scripts/memory-pg-recovery-test.mjs && node scripts/verify-embedding-runtime.mjs",
89
89
  "test:embedding-runtime:core": "node --test scripts/embedding-runtime-prune-test.mjs scripts/memory-pg-recovery-test.mjs && node scripts/verify-embedding-runtime.mjs --core",
90
90
  "test:embedding-runtime:warmup": "node scripts/verify-embedding-runtime.mjs --warmup",
91
+ "prepare:code-graph-test": "node scripts/prepare-code-graph-test.mjs",
91
92
  "test:code-graph-dispatch": "node --test scripts/code-graph-dispatch-test.mjs",
92
93
  "test:tui-queue": "node --test scripts/submit-commandbusy-race-test.mjs scripts/steering-drain-buckets-test.mjs scripts/abort-recovery-test.mjs scripts/message-rewind-test.mjs scripts/execution-pending-resume-kick-test.mjs scripts/execution-resume-esc-integration-test.mjs scripts/pending-stale-injection-test.mjs",
93
94
  "test:tui-input-render": "node --test scripts/prompt-immediate-render-test.mjs scripts/prompt-key-parity-test.mjs",
94
95
  "test:tui-streaming-window": "node --test scripts/streaming-tail-window-test.mjs scripts/tui-store-frame-batch-test.mjs && node scripts/tui-transcript-jitter-harness.mjs",
95
96
  "test:tui-ambiguous-width": "node --test scripts/tui-ambiguous-width-test.mjs",
96
97
  "test:release-assets": "node --check scripts/verify-release-assets.mjs && node --check scripts/verify-release-assets-test.mjs && node --check scripts/deploy-workflow-test.mjs && node --check scripts/release-version-discipline-test.mjs && node --test scripts/verify-release-assets-test.mjs scripts/deploy-workflow-test.mjs scripts/release-version-discipline-test.mjs",
97
- "test:release-focused": "npm run test:release-assets && npm run test:tool-contracts && npm run test:tool-batching && npm run test:placeholder && npm run smoke:patch && npm run test:patch-binary-cache && npm run test:providers && npm run test:deferred-tools && npm run smoke:compact && npm run test:compact && npm run test:context && node --test scripts/code-graph-root-federation-test.mjs scripts/code-graph-aggregate-cwd-test.mjs && npm run test:code-graph-dispatch && node --test scripts/code-graph-disk-hit-test.mjs && npm run test:shellhardening && npm run test:project-registry && npm run test:session && npm run test:workflow-editor && npm run test:embedding-runtime && node --test scripts/tui-transcript-perf-test.mjs",
98
+ "test:release-focused": "npm run test:release-focused:contracts && npm run test:release-focused:providers && npm run test:release-focused:compact && npm run test:release-focused:session",
99
+ "test:release-focused:contracts": "npm run test:release-assets && npm run test:tool-contracts && npm run test:tool-batching && npm run test:placeholder && npm run smoke:patch && npm run test:patch-binary-cache",
100
+ "test:release-focused:providers": "npm run test:providers && npm run test:deferred-tools && npm run test:compact && npm run test:context && npm run prepare:code-graph-test && node --test scripts/code-graph-root-federation-test.mjs scripts/code-graph-aggregate-cwd-test.mjs && npm run test:code-graph-dispatch && node --test scripts/code-graph-disk-hit-test.mjs",
101
+ "test:release-focused:compact": "npm run smoke:compact && npm run test:shellhardening",
102
+ "test:release-focused:session": "npm run test:project-registry && npm run test:session && npm run test:workflow-editor && npm run test:embedding-runtime && node --test scripts/tui-transcript-perf-test.mjs",
98
103
  "test:native-edit-wire": "node --test scripts/native-edit-wire-test.mjs",
99
104
  "test:patch-binary-cache": "node --test scripts/patch-binary-cache-test.mjs",
100
105
  "test:patch-parity": "node --test scripts/v4a-parity-test.mjs",
@@ -155,9 +160,11 @@
155
160
  "pg": "^8.22.0",
156
161
  "puppeteer-core": "^25.2.0",
157
162
  "react": "^19.2.7",
163
+ "sharp": "^0.35.3",
158
164
  "string-width": "^8.2.1",
159
165
  "strip-ansi": "^7.2.0",
160
166
  "tiktoken": "^1.0.22",
167
+ "unpdf": "^1.8.0",
161
168
  "undici": "^8.5.0",
162
169
  "wrap-ansi": "^10.0.0",
163
170
  "ws": "^8.21.0",
@@ -0,0 +1,30 @@
1
+ #!/usr/bin/env node
2
+
3
+ import { createHash } from 'node:crypto'
4
+ import { readFile } from 'node:fs/promises'
5
+ import { resolve } from 'node:path'
6
+ import { pathToFileURL } from 'node:url'
7
+
8
+ import { normalizeRuntimeLockfile } from './runtime-dependency-cache-key.mjs'
9
+
10
+ export const DEPENDENCY_LOCK_CACHE_SCHEMA = 1
11
+
12
+ export function dependencyLockCacheKey(lockfile) {
13
+ const normalized = normalizeRuntimeLockfile(lockfile)
14
+ const fingerprint = createHash('sha256')
15
+ .update(JSON.stringify(normalized))
16
+ .digest('hex')
17
+ return `dependency-lock-v${DEPENDENCY_LOCK_CACHE_SCHEMA}-${fingerprint}`
18
+ }
19
+
20
+ const invokedPath = process.argv[1] ? pathToFileURL(resolve(process.argv[1])).href : ''
21
+ if (invokedPath === import.meta.url) {
22
+ const lockfilePath = process.argv[2]
23
+ if (!lockfilePath) throw new Error('Usage: dependency-lock-cache-key.mjs <package-lock.json>')
24
+ readFile(resolve(lockfilePath))
25
+ .then((lockfile) => process.stdout.write(`${dependencyLockCacheKey(lockfile)}\n`))
26
+ .catch((error) => {
27
+ process.stderr.write(`Dependency lock cache key failed: ${error?.message || error}\n`)
28
+ process.exitCode = 1
29
+ })
30
+ }
@@ -9,19 +9,19 @@ kind: retrieval
9
9
  Return only WHERE (`path:line`), never WHY. You ARE `explore`; never call it.
10
10
  Use only grep/find/glob/code_graph; `read` and `list` are forbidden.
11
11
 
12
- Turn 1 (`turn 1/3`) is the whole search and should already mint anchors. Split
13
- broad/uncertain input into every known facet and send one batch under the
14
- shared one-route contract. Route each facet to the cheapest anchor source:
15
- `code_graph` `symbol_search` whenever the facet names a plausible
16
- symbol/identifier; grep `content_with_context` with `pattern[]` of 4–8
17
- code-token variants for concept facets its hits carry `path:line`, cite them
18
- directly instead of re-mining; `find` `query[]` ONLY when the target is itself
19
- a file/dir name or an unverified path fragment, never as a default extra
20
- facet. For a symptom/behavior query, add the upstream producer/derivation
21
- layer of the reported surface as extra facets in the SAME batch, never as a
22
- later turn. Follow-up turns batch every unresolved facet in parallel; a
23
- single-tool turn is allowed only when exactly one pre-anchor/zero-hit facet
24
- remains.
12
+ Turn 1 (`turn 1/3`) is the whole search and should already mint anchors.
13
+ Extract every concrete locator facet implied by the query, deduplicate
14
+ overlap, and send the maximum independent fan-out in one batch under the
15
+ shared one-route contract; never cap query or facet count. Route each facet to
16
+ exactly one cheapest anchor source: `code_graph` `symbol_search` when it names
17
+ a plausible symbol/identifier; grep `content_with_context` with `pattern[]` of
18
+ 4–8 code-token variants for a concept or quoted error; `find` `query[]` only
19
+ when the target itself is a file/dir name or unverified path fragment. For a
20
+ symptom/behavior query, include the reported surface and its immediate
21
+ producer as sibling facets in the same batch when both are needed to locate
22
+ the behavior. Never add generic repository metadata, tests, docs, or adjacent
23
+ subsystems unless the query asks for them, and never send the same facet to
24
+ multiple tools merely for confidence.
25
25
 
26
26
  Grep defaults to `output_mode:"content_with_context"` with `context:0`
27
27
  (matches only — the match line already carries its citable `path:line`) and a
@@ -35,33 +35,44 @@ queries to English identifiers.
35
35
  Scope is every `<roots><root>…</root></roots>` entry when supplied, otherwise
36
36
  session cwd. Search every supplied root in the turn-1 batch: grep/glob batch
37
37
  `path[]`, while find uses one sibling call per root. Never silently fall back to
38
- cwd or omit a supplied root. A find result is relative to its exact root; prefix
39
- that root when returning a path outside cwd. For unverified `src` paths, use
40
- `find` first; never guess or invent directories or pair `path:"."` with guessed
41
- `src/**`. Scoped grep/glob may use only a supplied root or an exact find-returned
42
- path. After zero hits, change tokens or scope, never wording or guessed paths.
38
+ cwd or omit a supplied root. A tool-returned path is immutable evidence: copy
39
+ it exactly and never join, prefix, normalize, repair, or reconstruct it from a
40
+ root. A relative find result is a pre-anchor for code-location queries and may
41
+ only scope a later grep; for file/dir-location queries it may be returned
42
+ verbatim. For unverified `src` paths, use `find` first; never guess or invent
43
+ directories or pair `path:"."` with guessed `src/**`. Scoped grep/glob may use
44
+ only a supplied root or an exact find-returned path. After zero hits, change
45
+ tokens or scope, never wording or guessed paths.
43
46
 
44
47
  An anchor is a `path:line` containing a query token or synonym, including a
45
48
  code_graph hit. Generic terms without query specificity are zero. Never
46
- re-locate, reconfirm, or upgrade an anchor. A path without `:line` is a
47
- pre-anchor and counts as zero. After every result, stop and answer on any
48
- specific-token anchor; mark a weak anchor `?`.
49
+ re-locate, reconfirm, upgrade, or weaken an anchor. A path without `:line` is a
50
+ pre-anchor and counts as zero for code locations. After every result, stop and
51
+ answer as soon as the query's locator objective has specific-token anchors;
52
+ never return a weak or merely plausible anchor.
49
53
 
50
- For a code-location query left only with pre-anchors, the sole anchor-minting
51
- follow-up is one scoped `content_with_context` grep with `head_limit` on those
52
- paths. If it returns zero, recovery may continue within budget with changed
53
- tokens or scope. Never make a second minting hop or fabricate/estimate a line.
54
+ Turn 2 is allowed only when turn 1 has zero valid anchors for the locator
55
+ objective. Batch every unresolved facet at once with changed concrete tokens
56
+ or scope; never repeat the same tokens and scope. Exact pre-anchors may use
57
+ one batched scoped `content_with_context` grep with `head_limit` to mint
58
+ coordinates.
54
59
 
55
- Use at most 3 turns and label every tool message `turn N/3`; normally use one
56
- batch and one answer. Turns 2–3 are allowed only when turn 1 has zero anchors.
57
- The first matching entry/definition anchors a concept, value, or default; never
58
- trace its chain. Only an explicit flow or default-resolution query with an
59
- entry anchor but no resolved value may use turn 2 for one resolving hop.
60
+ Turn 3 is allowed only when turn 2 produced new exact pre-anchors, or an
61
+ explicit flow/default-resolution query has entry anchors but still needs one
62
+ dependent hop. Batch every final scoped grep or resolving hop at once. Never
63
+ start a broad search, add a facet, or merely reword the query on turn 3; if
64
+ these conditions are absent, return `EXPLORATION_FAILED` after turn 2.
65
+
66
+ Use at most 3 tool turns and label every tool message `turn N/3`; normally use
67
+ one batch and one answer. An allowed turn is not a target: fail as soon as the
68
+ next turn lacks a concrete evidence-producing move. The first matching
69
+ entry/definition anchors a concept, value, or default; never trace its chain
70
+ unless the query explicitly asks for flow/default resolution.
60
71
 
61
72
  Answer in at most 3 lines, most specific first:
62
73
  `path:line — symbol — short reason`. Copy every cited `path:line` verbatim from
63
74
  a tool result in this session; never estimate, adjust, or recall it. Every
64
75
  code-location line requires `:line`; never return a bare filename or vague
65
76
  prose. A file/dir-location query may return an exact verified path without
66
- `:line`. Return `EXPLORATION_FAILED` only after spending the budget with zero
67
- anchors; first prefer a weak anchor to a false miss.
77
+ `:line`. Return `EXPLORATION_FAILED` when the bounded recovery rules above
78
+ cannot produce a verified anchor; never fabricate, estimate, or soften one.
@@ -1,5 +1,5 @@
1
- import { createHash } from 'crypto';
2
1
  import { isInclusiveProvider } from '../../shared/llm/cost.mjs';
2
+ import { estimateJsonBytes, hashStructuredValue } from '../../shared/json-metrics.mjs';
3
3
  import {
4
4
  appendAgentTrace,
5
5
  drainAgentTrace,
@@ -17,7 +17,23 @@ import {
17
17
 
18
18
  function estimateProviderPayloadBytes(messages, model, tools) {
19
19
  try {
20
- return Buffer.byteLength(JSON.stringify({ model, messages, tools: tools || [] }), 'utf8');
20
+ let referencedBytes = 0;
21
+ const seen = new Set();
22
+ const walk = (value) => {
23
+ if (!value || typeof value !== 'object' || seen.has(value)) return;
24
+ seen.add(value);
25
+ if (typeof value.attachmentRef === 'string') {
26
+ const bytes = Number(value.sizeBytes) || 0;
27
+ referencedBytes += value.type === 'image' || value.type === 'file'
28
+ ? Math.ceil(bytes / 3) * 4
29
+ : bytes;
30
+ return;
31
+ }
32
+ if (Array.isArray(value)) value.forEach(walk);
33
+ else Object.values(value).forEach(walk);
34
+ };
35
+ walk(messages);
36
+ return estimateJsonBytes({ model, messages, tools: tools || [] }) + referencedBytes;
21
37
  }
22
38
  catch {
23
39
  return null;
@@ -60,9 +76,7 @@ function extractCacheWriteTokens(usage) {
60
76
  // hashing megabytes per turn. Truncated SHA1 keeps the trace row compact.
61
77
  function messagePrefixHash(messages) {
62
78
  try {
63
- const json = JSON.stringify(messages || []);
64
- const slice = json.length > 4096 ? json.slice(0, 4096) : json;
65
- return createHash('sha1').update(slice).digest('hex').slice(0, 12);
79
+ return hashStructuredValue(messages || [], { maxStringChars: 4096 }).slice(0, 12);
66
80
  } catch {
67
81
  return null;
68
82
  }
@@ -112,6 +112,13 @@ function _sanitizeGrokModels(models) {
112
112
  const display = _displayGrokModel(m);
113
113
  const family = _normalizeGrokFamily(m?.id);
114
114
  const reasoningLevels = _grokModelSupportsEffort(m?.id) ? ['none', 'low', 'medium', 'high'] : [];
115
+ // api.x.ai /models does not expose a max-output field. Historical
116
+ // enrichment could persist contextWindow as outputTokens (for example
117
+ // 500k/500k); clear it rather than advertise an invented request cap.
118
+ if (out?.outputTokens != null) {
119
+ changed = true;
120
+ out = { ...out, outputTokens: null };
121
+ }
115
122
  if (display && display !== out?.display) {
116
123
  changed = true;
117
124
  out = { ...out, display, name: out?.name || out?.id };
@@ -1,4 +1,9 @@
1
1
  import { createHash } from 'node:crypto';
2
+ import {
3
+ attachmentTextForPart,
4
+ isAttachmentReference,
5
+ readAttachmentBase64,
6
+ } from '../../../attachments/store.mjs';
2
7
 
3
8
  const DEFAULT_IMAGE_MIME = 'image/png';
4
9
 
@@ -9,6 +14,9 @@ function cleanMimeType(value) {
9
14
 
10
15
  function imageInfo(block) {
11
16
  if (!block || typeof block !== 'object' || block.type !== 'image') return null;
17
+ if (isAttachmentReference(block)) {
18
+ return { data: readAttachmentBase64(block), mimeType: cleanMimeType(block.mimeType || block.mediaType) };
19
+ }
12
20
  if (typeof block.data === 'string' && block.data) {
13
21
  return { data: block.data, mimeType: cleanMimeType(block.mimeType || block.mediaType) };
14
22
  }
@@ -32,10 +40,13 @@ function geminiInlineInfo(block) {
32
40
  // as their native document shape; estimators must never serialize its base64.
33
41
  function fileInfo(block) {
34
42
  if (!block || typeof block !== 'object' || block.type !== 'file') return null;
35
- if (typeof block.data !== 'string' || !block.data) return null;
43
+ const data = isAttachmentReference(block)
44
+ ? readAttachmentBase64(block)
45
+ : (typeof block.data === 'string' ? block.data : '');
46
+ if (!data) return null;
36
47
  const mimeType = String(block.mimeType || block.mediaType || 'application/pdf').trim().toLowerCase() || 'application/pdf';
37
48
  const filename = typeof block.filename === 'string' && block.filename ? block.filename : '';
38
- return { data: block.data, mimeType, filename };
49
+ return { data, mimeType, filename };
39
50
  }
40
51
 
41
52
  function imageUrlFromPart(block) {
@@ -95,6 +106,7 @@ function imageMimeFromDataUrl(url) {
95
106
  function textFromPart(block) {
96
107
  if (typeof block === 'string') return block;
97
108
  if (!block || typeof block !== 'object') return '';
109
+ if (block.type === 'text' && isAttachmentReference(block)) return attachmentTextForPart(block);
98
110
  if (typeof block.text === 'string') return block.text;
99
111
  if (typeof block.content === 'string') return block.content;
100
112
  return '';
@@ -112,7 +124,7 @@ function contentParts(content) {
112
124
  if (content && typeof content === 'object'
113
125
  && (imageUrlFromPart(content) || imageFileIdFromPart(content)
114
126
  || imageFileUriFromPart(content) || geminiInlineInfo(content)
115
- || fileInfo(content))) return [content];
127
+ || fileInfo(content) || isAttachmentReference(content))) return [content];
116
128
  return null;
117
129
  }
118
130
 
@@ -149,6 +161,14 @@ function imageIdentity(kind, value, mimeType = '') {
149
161
 
150
162
  function imageDescriptor(part) {
151
163
  if (!part || typeof part !== 'object') return null;
164
+ if (part.type === 'image' && isAttachmentReference(part)) {
165
+ return {
166
+ identity: imageIdentity('attachment-ref', part.attachmentRef, part.mimeType || part.mediaType),
167
+ width: positiveDimension(part.width, part.dimensions?.width),
168
+ height: positiveDimension(part.height, part.dimensions?.height),
169
+ detail: String(part.detail || 'auto').toLowerCase(),
170
+ };
171
+ }
152
172
  const info = imageInfo(part);
153
173
  const inline = geminiInlineInfo(part);
154
174
  const url = imageUrlFromPart(part);
@@ -187,6 +207,9 @@ export function contentFileDescriptors(content) {
187
207
  const parts = contentParts(content);
188
208
  if (!parts) return [];
189
209
  return parts.flatMap((part) => {
210
+ if (part?.type === 'file' && isAttachmentReference(part)) {
211
+ return [{ mimeType: String(part.mimeType || part.mediaType || 'application/pdf'), sizeBytes: Number(part.sizeBytes) || 0 }];
212
+ }
190
213
  const file = fileInfo(part);
191
214
  if (!file) return [];
192
215
  return [{ mimeType: file.mimeType, sizeBytes: Math.floor((file.data.length * 3) / 4) }];
@@ -215,6 +238,7 @@ function storedHistoryImagePlaceholder(part) {
215
238
  function sanitizePartForStoredHistory(part) {
216
239
  if (typeof part === 'string') return part;
217
240
  if (!part || typeof part !== 'object') return part;
241
+ if (isAttachmentReference(part)) return part;
218
242
  if (part.type === 'image' || part.type === 'image_url' || part.type === 'input_image' || imageUrlFromPart(part) || imageFileIdFromPart(part) || imageFileUriFromPart(part) || geminiInlineInfo(part)) {
219
243
  return { type: 'text', text: storedHistoryImagePlaceholder(part) };
220
244
  }
@@ -307,6 +331,9 @@ export function normalizeContentForAnthropic(content) {
307
331
  if (part?.type === 'input_text' || part?.type === 'output_text') {
308
332
  return { type: 'text', text: part.text || '' };
309
333
  }
334
+ if (part?.type === 'text' && isAttachmentReference(part)) {
335
+ return { type: 'text', text: attachmentTextForPart(part) };
336
+ }
310
337
  return part;
311
338
  });
312
339
  }
@@ -360,7 +360,7 @@ function _modelsDevMetadataSync(id, provider) {
360
360
  if (!pid) return null;
361
361
  if (!_mdCache) {
362
362
  warmModelsDevFromDiskSync();
363
- if (!_mdCache) { void loadModelsDevCatalog(); return null; }
363
+ if (!_mdCache) return null;
364
364
  }
365
365
  const row = _mdCache?.[pid]?.models?.[id];
366
366
  if (!row || !row.cost) return null;
@@ -372,10 +372,12 @@ function _modelsDevMetadataSync(id, provider) {
372
372
  // require a `cost` field (staleness only needs family/release_date) and
373
373
  // returns the raw row untouched. Warms from disk one-shot if memory is cold;
374
374
  // returns null when the catalog is unavailable so callers can skip filtering.
375
+ // Sync reads never start network work: session warmup owns remote catalog I/O,
376
+ // and injected/provider-local transports must remain request-hermetic.
375
377
  // `_test` (tests only) injects a fake catalog map without touching disk.
376
378
  export function getModelsDevRowSync(id, provider, _test) {
377
379
  const cat = _test || _mdCache || (warmModelsDevFromDiskSync(), _mdCache);
378
- if (!cat) { void loadModelsDevCatalog(); return null; }
380
+ if (!cat) return null;
379
381
  const pid = _modelsDevProviderId(provider);
380
382
  if (!pid) return null;
381
383
  const row = cat?.[pid]?.models?.[id];
@@ -387,7 +389,7 @@ export function getModelsDevRowSync(id, provider, _test) {
387
389
  // provider's whole catalog. Returns null when the catalog is cold/unavailable.
388
390
  export function getModelsDevProviderModelsSync(provider, _test) {
389
391
  const cat = _test || _mdCache || (warmModelsDevFromDiskSync(), _mdCache);
390
- if (!cat) { void loadModelsDevCatalog(); return null; }
392
+ if (!cat) return null;
391
393
  const pid = _modelsDevProviderId(provider);
392
394
  if (!pid) return null;
393
395
  const models = cat?.[pid]?.models;
@@ -31,6 +31,22 @@ function readModelsFromCacheFile(fileName) {
31
31
  }
32
32
  }
33
33
 
34
+ export function providerCachedModelsSync(provider) {
35
+ const p = String(provider || '').toLowerCase();
36
+ const files = PROVIDER_CACHE_FILES[p] || [];
37
+ const rows = [];
38
+ const seen = new Set();
39
+ for (const fileName of files) {
40
+ for (const row of readModelsFromCacheFile(fileName)) {
41
+ const id = rowId(row);
42
+ if (!id || seen.has(id)) continue;
43
+ seen.add(id);
44
+ rows.push({ ...row, id });
45
+ }
46
+ }
47
+ return rows;
48
+ }
49
+
34
50
  function modelAliases(id) {
35
51
  const text = String(id || '').trim();
36
52
  if (!text) return [];
@@ -60,11 +76,17 @@ function getProviderCachedModelSync(provider, model) {
60
76
  }
61
77
 
62
78
  export function providerCachedModelMetadataSync(provider, model) {
79
+ const p = String(provider || '').toLowerCase();
63
80
  const row = getProviderCachedModelSync(provider, model);
64
81
  if (!row) return null;
65
82
  return {
66
83
  contextWindow: num(row.contextWindow ?? row.context_window ?? row.maxContextWindow ?? row.max_context_window ?? row.max_input_tokens),
67
- outputTokens: num(row.outputTokens ?? row.maxOutputTokens ?? row.max_output_tokens ?? row.output_token_limit),
84
+ // Grok's /models response currently publishes a context window but no
85
+ // output ceiling. Older enriched cache rows copied contextWindow into
86
+ // outputTokens; never feed that derived value back as provider-native.
87
+ outputTokens: p === 'grok-oauth'
88
+ ? null
89
+ : num(row.outputTokens ?? row.maxOutputTokens ?? row.max_output_tokens ?? row.output_token_limit),
68
90
  supportsVision: row.supportsVision === true,
69
91
  supportsFunctionCalling: row.supportsFunctionCalling === true,
70
92
  supportsWebSearch: row.supportsWebSearch === true,
@@ -9,6 +9,7 @@ import { readStreamOutcome } from '../../providers/lib/stream-outcome.mjs';
9
9
  import { normalizeCompactType, DEFAULT_COMPACT_TYPE } from '../compact.mjs';
10
10
  import { loadSession, saveSession, saveSessionAsync, saveSessionAsyncDeferred, readSessionLifecycleFromDisk } from '../store.mjs';
11
11
  import { createAbortController } from '../../../../shared/abort-controller.mjs';
12
+ import { estimateJsonBytes } from '../../../../shared/json-metrics.mjs';
12
13
  import { logLlmCall } from '../../../../shared/llm/usage-log.mjs';
13
14
  import { appendAgentTrace } from '../../agent-trace.mjs';
14
15
  import { recordStandaloneStatusTelemetry } from './status-telemetry.mjs';
@@ -568,7 +569,7 @@ export async function askSession(sessionId, prompt, context, onToolCall, cwdOver
568
569
  const _prefetchBytes = Buffer.byteLength(explicitPrefetchResult || '', 'utf8');
569
570
  const _promptBytes = promptContentBytes(prompt);
570
571
  const _userTurnBytes = promptContentBytes(_userTurnContent);
571
- const _messagesBytes = Buffer.byteLength(JSON.stringify(historyMessages || []), 'utf8');
572
+ const _messagesBytes = estimateJsonBytes(historyMessages || []);
572
573
  const _totalBytes = _userTurnBytes + _messagesBytes;
573
574
  appendAgentTrace({
574
575
  kind: 'context',
@@ -355,6 +355,9 @@ function normalizePendingMessageEntry(entry) {
355
355
  id: pendingMessageId(entry),
356
356
  enqueuedAt: Number(entry.enqueuedAt) || Date.now(),
357
357
  };
358
+ const entryOptions = entry.options && typeof entry.options === 'object'
359
+ ? { options: entry.options }
360
+ : {};
358
361
  const marker = entry.notificationKind === COMPLETION_NOTIFICATION_KIND
359
362
  ? { notificationKind: COMPLETION_NOTIFICATION_KIND, enqueuedAt: Number(entry.enqueuedAt) || Date.now() }
360
363
  : null;
@@ -366,13 +369,13 @@ function normalizePendingMessageEntry(entry) {
366
369
  if (content == null) return null;
367
370
  const text = typeof entry.text === 'string' ? entry.text.trim() : promptContentText(content).trim();
368
371
  let out = null;
369
- if (Array.isArray(content)) out = content.length > 0 ? { content, text } : null;
372
+ if (Array.isArray(content)) out = content.length > 0 ? { content, text, ...entryOptions } : null;
370
373
  else if (typeof content === 'string') {
371
374
  const value = content.trim();
372
- out = value ? { content: value, text: text || value } : null;
375
+ out = value ? { content: value, text: text || value, ...entryOptions } : null;
373
376
  } else {
374
377
  const fallback = promptContentText(content).trim();
375
- out = fallback ? { content: fallback, text: text || fallback } : null;
378
+ out = fallback ? { content: fallback, text: text || fallback, ...entryOptions } : null;
376
379
  }
377
380
  if (!out) return null;
378
381
  // The lifecycle token rides along every derived copy (see carryLifecycleToken).
@@ -394,7 +397,12 @@ function pendingMessageQueueEntry(entry) {
394
397
  const marker = isCompletionNotificationEntry(normalized)
395
398
  ? { notificationKind: COMPLETION_NOTIFICATION_KIND, enqueuedAt: normalized.enqueuedAt }
396
399
  : null;
397
- const base = { ...identity, content: normalized.content, text: normalized.text || promptContentText(normalized.content).trim() };
400
+ const base = {
401
+ ...identity,
402
+ content: normalized.content,
403
+ text: normalized.text || promptContentText(normalized.content).trim(),
404
+ ...(normalized.options ? { options: normalized.options } : {}),
405
+ };
398
406
  return carryLifecycleToken(marker ? { ...base, ...marker } : base, entry);
399
407
  }
400
408
 
@@ -427,6 +435,20 @@ function normalizePersistedEntry(entry, options = {}) {
427
435
  const message = entry.message.trim();
428
436
  return message ? { id, message, enqueuedAt } : null;
429
437
  }
438
+ if (Object.prototype.hasOwnProperty.call(entry, 'content')) {
439
+ const normalized = normalizePendingMessageEntry(entry);
440
+ if (!normalized) return null;
441
+ if (typeof normalized.content === 'string' && !normalized.options) {
442
+ return { id, message: normalized.text || normalized.content, enqueuedAt };
443
+ }
444
+ return {
445
+ id,
446
+ content: normalized.content,
447
+ text: normalized.text,
448
+ ...(normalized.options ? { options: normalized.options } : {}),
449
+ enqueuedAt,
450
+ };
451
+ }
430
452
  const t = pendingMessageText(entry);
431
453
  return t ? { id, message: t, enqueuedAt } : null;
432
454
  }
@@ -1195,10 +1217,26 @@ async function _flushForeignDrainBatch() {
1195
1217
  && !isCompletionNotificationEntry(entry)
1196
1218
  && !isLegacyUnmarkedCompletionNotification(text)
1197
1219
  && text && !isInternalRuntimeNotificationText(text);
1220
+ const normalized = normalizePendingMessageEntry(entry);
1221
+ const structured = Array.isArray(normalized?.content) || Boolean(normalized?.options);
1198
1222
  if (foreignUser && isStaleUserInjection(entry)) {
1199
- taken.push({ text: lateDeliveryText(text, entry), id });
1223
+ const lateText = lateDeliveryText(text, entry);
1224
+ const content = Array.isArray(normalized?.content)
1225
+ ? [{ type: 'text', text: `${lateText.slice(0, lateText.length - text.length)}` }, ...normalized.content]
1226
+ : lateText;
1227
+ taken.push({
1228
+ ...(structured ? { content } : {}),
1229
+ text: lateText,
1230
+ id,
1231
+ ...(normalized?.options ? { options: normalized.options } : {}),
1232
+ });
1200
1233
  } else if (foreignUser) {
1201
- taken.push({ text, id });
1234
+ taken.push({
1235
+ ...(structured ? { content: normalized?.content ?? text } : {}),
1236
+ text,
1237
+ id,
1238
+ ...(normalized?.options ? { options: normalized.options } : {}),
1239
+ });
1202
1240
  } else {
1203
1241
  kept.push(entry);
1204
1242
  }
@@ -4,13 +4,14 @@ import { existsSync, readFileSync } from 'node:fs';
4
4
  import { join } from 'node:path';
5
5
  import { isInternalRuntimeNotificationText as contractIsInternalRuntimeNotificationText } from '../../../../shared/tool-execution-contract.mjs';
6
6
  import { SUMMARY_PREFIX } from '../compact.mjs';
7
+ import { attachmentTextForPart, isAttachmentReference } from '../../../../attachments/store.mjs';
7
8
 
8
9
  export function promptContentText(content) {
9
10
  if (typeof content === 'string') return content;
10
11
  if (Array.isArray(content)) {
11
12
  return content.map((part) => {
12
13
  if (typeof part === 'string') return part;
13
- if (part?.type === 'text') return part.text || '';
14
+ if (part?.type === 'text') return isAttachmentReference(part) ? attachmentTextForPart(part) : (part.text || '');
14
15
  if (part?.type === 'image') return '[Image]';
15
16
  return part?.text || '';
16
17
  }).filter(Boolean).join('\n');
@@ -4,12 +4,9 @@ import { renameWithRetrySync } from '../../../../shared/atomic-file.mjs';
4
4
  import { sanitizeContentForStoredHistory } from '../../providers/media-normalization.mjs';
5
5
  import { readTopLevelLifecycleRecord, isLifecycleUnreadable } from '../lifecycle-scan.mjs';
6
6
 
7
- // The live in-memory session (and every model request)
8
- // retains attached image bytes across turns so multi-turn recognition works.
9
- // The persisted session JSON, however, replaces image content with a short
10
- // text placeholder at serialization time — keeping session files small without
11
- // starving the model of the image mid-conversation. Returns the same object
12
- // reference when nothing changed (no-image sessions pay only a shallow scan).
7
+ // Inline legacy media is replaced with placeholders on disk. New prompt media
8
+ // already arrives as durable content-addressed refs, so session JSON stays small
9
+ // while provider lowering can still resolve it across turns.
13
10
  // Per-message disk projection shared by _sessionForDisk and the save-worker
14
11
  // delta path (tail-only projection). Returns the SAME array reference when no
15
12
  // message changed. Idempotent: already-sanitized content passes through