mixdog 0.9.96 → 0.9.99
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 +0 -17
- package/package.json +16 -8
- package/scripts/build-tui.mjs +6 -6
- package/scripts/{channel-daemon-stub.mjs → daemon-stub.mjs} +18 -12
- package/src/defaults/skills/setup/SKILL.md +1 -1
- package/src/lib/keychain-cjs.cjs +1 -1
- package/src/lib/mixdog-debug.cjs +2 -3
- package/src/rules/agent/30-explorer.md +43 -32
- package/src/rules/lead/01-general.md +1 -2
- package/src/rules/lead/lead-tool.md +0 -3
- package/src/rules/shared/01-tool.md +11 -10
- package/src/runtime/agent/orchestrator/agent-trace-io.mjs +4 -13
- package/src/runtime/agent/orchestrator/agent-trace.mjs +19 -5
- package/src/runtime/agent/orchestrator/mcp/client.mjs +81 -33
- package/src/runtime/agent/orchestrator/providers/grok-oauth.mjs +7 -0
- package/src/runtime/agent/orchestrator/providers/media-normalization.mjs +30 -3
- package/src/runtime/agent/orchestrator/providers/model-catalog.mjs +5 -3
- package/src/runtime/agent/orchestrator/providers/openai-ws-stream.mjs +3 -6
- package/src/runtime/agent/orchestrator/providers/provider-catalog-cache.mjs +23 -1
- package/src/runtime/agent/orchestrator/providers/registry.mjs +2 -2
- package/src/runtime/agent/orchestrator/session/agent-loop.mjs +8 -6
- package/src/runtime/agent/orchestrator/session/compact.mjs +1 -1
- package/src/runtime/agent/orchestrator/session/eager-dispatch.mjs +22 -4
- package/src/runtime/agent/orchestrator/session/loop/tool-exec.mjs +20 -2
- package/src/runtime/agent/orchestrator/session/loop/tool-helpers.mjs +4 -3
- package/src/runtime/agent/orchestrator/session/manager/ask-session.mjs +42 -26
- package/src/runtime/agent/orchestrator/session/manager/compaction-runner.mjs +2 -3
- package/src/runtime/agent/orchestrator/session/manager/pending-messages.mjs +44 -6
- package/src/runtime/agent/orchestrator/session/manager/prefetch-bridge.mjs +12 -6
- package/src/runtime/agent/orchestrator/session/manager/prompt-utils.mjs +2 -1
- package/src/runtime/agent/orchestrator/session/manager/session-lock.mjs +38 -2
- package/src/runtime/agent/orchestrator/session/store/serialize.mjs +3 -6
- package/src/runtime/agent/orchestrator/session/store/summary-cache.mjs +10 -3
- package/src/runtime/agent/orchestrator/session/store-summary-index.mjs +4 -0
- package/src/runtime/agent/orchestrator/session/store-summary-reader.mjs +59 -42
- package/src/runtime/agent/orchestrator/session/tool-batch.mjs +3 -2
- package/src/runtime/agent/orchestrator/tools/builtin/bash-tool.mjs +12 -22
- package/src/runtime/agent/orchestrator/tools/builtin/binary-file.mjs +92 -19
- package/src/runtime/agent/orchestrator/tools/builtin/fs-reachability.mjs +2 -2
- package/src/runtime/agent/orchestrator/tools/builtin/list-tool.mjs +2 -0
- package/src/runtime/agent/orchestrator/tools/builtin/read-image-resize.mjs +103 -12
- package/src/runtime/agent/orchestrator/tools/builtin/read-image.mjs +4 -4
- package/src/runtime/agent/orchestrator/tools/builtin/read-single-tool.mjs +80 -30
- package/src/runtime/agent/orchestrator/tools/builtin/read-special-files.mjs +10 -30
- package/src/runtime/agent/orchestrator/tools/builtin/read-tool.mjs +41 -22
- package/src/runtime/agent/orchestrator/tools/builtin/rg-runner.mjs +6 -2
- package/src/runtime/agent/orchestrator/tools/code-graph/dispatch.mjs +55 -11
- package/src/runtime/agent/orchestrator/tools/code-graph/search-references.mjs +12 -1
- package/src/runtime/agent/orchestrator/tools/code-graph/search.mjs +55 -18
- package/src/runtime/agent/orchestrator/tools/code-graph-tool-defs.mjs +1 -0
- package/src/runtime/agent/orchestrator/tools/env-scrub.mjs +1 -2
- package/src/runtime/agent/orchestrator/tools/lib/pwsh-standby-pool.mjs +2 -2
- package/src/runtime/agent/orchestrator/tools/patch/native-server.mjs +1 -1
- package/src/runtime/agent/orchestrator/tools/patch/orchestrator.mjs +94 -105
- package/src/runtime/agent/orchestrator/tools/patch-tool-defs.mjs +2 -1
- package/src/runtime/agent/orchestrator/tools/patch.mjs +1 -1
- package/src/runtime/agent/orchestrator/tools/shell-command.mjs +7 -1
- package/src/runtime/attachments/pdf-extract.mjs +83 -0
- package/src/runtime/attachments/store.mjs +515 -0
- package/src/runtime/attachments/store.test.mjs +188 -0
- package/src/runtime/channels/lib/config.mjs +33 -33
- package/src/runtime/channels/lib/inbound-handler.mjs +7 -7
- package/src/runtime/channels/lib/interaction-handlers.mjs +4 -194
- package/src/runtime/channels/lib/memory-client.mjs +31 -157
- package/src/runtime/channels/lib/output-forwarder.mjs +16 -16
- package/src/runtime/channels/lib/owned-runtime.mjs +60 -60
- package/src/runtime/channels/lib/owner-heartbeat.mjs +1 -1
- package/src/runtime/channels/lib/{backend-dispatch.mjs → provider-dispatch.mjs} +6 -6
- package/src/runtime/channels/lib/runtime-paths.mjs +5 -34
- package/src/runtime/channels/lib/status-snapshot.mjs +3 -3
- package/src/runtime/channels/lib/tool-dispatch.mjs +4 -4
- package/src/runtime/channels/lib/worker-bootstrap.mjs +1 -1
- package/src/runtime/channels/lib/worker-ipc.mjs +4 -70
- package/src/runtime/channels/lib/worker-main.mjs +39 -58
- package/src/runtime/channels/{backends → providers}/discord-access.mjs +1 -1
- package/src/runtime/channels/{backends → providers}/discord-gateway.mjs +1 -1
- package/src/runtime/channels/{backends → providers}/discord.mjs +3 -3
- package/src/runtime/channels/{backends → providers}/telegram.mjs +12 -12
- package/src/runtime/channels/tool-defs.mjs +1 -1
- package/src/runtime/memory/index.mjs +72 -211
- package/src/runtime/memory/lib/agent-ipc.mjs +9 -8
- package/src/runtime/memory/lib/cycle-llm-adapters.mjs +2 -2
- package/src/runtime/memory/lib/http-router.mjs +1 -6
- package/src/runtime/memory/lib/http-wire.mjs +39 -4
- package/src/runtime/memory/lib/memory-config-flags.mjs +1 -1
- package/src/runtime/memory/lib/memory-cycle2-shared.mjs +4 -1
- package/src/runtime/memory/lib/memory-daemon-lifecycle.mjs +5 -0
- package/src/runtime/memory/lib/memory-log.mjs +1 -1
- package/src/runtime/memory/lib/memory-process-lock.mjs +5 -60
- package/src/runtime/memory/lib/memory.mjs +1 -1
- package/src/runtime/memory/lib/session-ingest.mjs +1 -1
- package/src/runtime/search/index.mjs +2 -2
- package/src/runtime/shared/abort-race.mjs +70 -0
- package/src/runtime/shared/child-spawn-gate.mjs +101 -63
- package/src/runtime/shared/config.mjs +11 -24
- package/src/runtime/shared/json-metrics.mjs +94 -0
- package/src/runtime/shared/llm/index.mjs +1 -1
- package/src/runtime/shared/owner-fair-gate.mjs +134 -0
- package/src/runtime/shared/resource-admission.mjs +6 -5
- package/src/runtime/shared/tool-execution-owner.mjs +12 -0
- package/src/runtime/shared/tool-result-summary.mjs +1 -1
- package/src/runtime/shared/tool-surface.mjs +91 -22
- package/src/runtime/shared/tool-workload-gates.mjs +29 -0
- package/src/runtime/shared/turn-snapshot.mjs +30 -2
- package/src/runtime/shared/turn-worktree-snapshot.mjs +19 -0
- package/src/session-runtime/channel-config-api.mjs +5 -5
- package/src/session-runtime/config-lifecycle.mjs +27 -27
- package/src/session-runtime/lifecycle-api.mjs +22 -3
- package/src/session-runtime/memory-runtime-prewarm.mjs +15 -0
- package/src/session-runtime/prewarm.mjs +1 -1
- package/src/session-runtime/provider-models.mjs +12 -2
- package/src/session-runtime/quick-model-rows.mjs +38 -7
- package/src/session-runtime/quick-search-models.mjs +15 -17
- package/src/session-runtime/remote-control.mjs +6 -7
- package/src/session-runtime/runtime-core.mjs +61 -32
- package/src/session-runtime/runtime-tunables.mjs +9 -6
- package/src/session-runtime/self-update.mjs +1 -1
- package/src/session-runtime/session-lifecycle.mjs +36 -15
- package/src/session-runtime/session-title.mjs +28 -4
- package/src/session-runtime/session-turn-api.mjs +55 -15
- package/src/session-runtime/settings-api.mjs +7 -7
- package/src/standalone/agent-dispatch-broker.mjs +1 -1
- package/src/standalone/channel-admin.mjs +42 -53
- package/src/standalone/{channel-daemon-client.mjs → channel-client.mjs} +16 -18
- package/src/standalone/{channel-daemon-transport.mjs → channel-transport.mjs} +73 -58
- package/src/standalone/channel-worker.mjs +124 -647
- package/src/standalone/{backend-daemon.mjs → daemon.mjs} +190 -132
- package/src/standalone/memory-runtime-proxy.mjs +58 -1
- package/src/standalone/{engine-daemon-client.mjs → session-client.mjs} +151 -86
- package/src/standalone/session-protocol.mjs +9 -4
- package/src/standalone/session-runtime-pool.mjs +393 -0
- package/src/standalone/session-runtime-record.mjs +20 -0
- package/src/standalone/session-runtime-worker.mjs +203 -0
- package/src/standalone/{engine-daemon-service.mjs → session-service.mjs} +192 -157
- package/src/standalone/{engine-daemon-transport.mjs → session-transport.mjs} +142 -63
- package/src/standalone/session-wire.mjs +55 -0
- package/src/tui/App.jsx +4 -4
- package/src/tui/app/app-format.mjs +1 -1
- package/src/tui/app/channel-pickers.mjs +41 -41
- package/src/tui/app/input-parsers.mjs +2 -2
- package/src/tui/app/onboarding-steps.mjs +2 -2
- package/src/tui/app/project-picker.mjs +8 -8
- package/src/tui/app/prompt-submit.mjs +15 -17
- package/src/tui/app/settings-picker.mjs +25 -25
- package/src/tui/app/use-prompt-handlers.mjs +7 -3
- package/src/tui/app/use-prompt-queue-history.mjs +1 -1
- package/src/tui/app/use-transcript-scroll.mjs +1 -1
- package/src/tui/components/ToolExecution.jsx +1 -1
- package/src/tui/components/prompt-input/restore-policy.mjs +1 -1
- package/src/tui/components/tool-execution/surface-detail.mjs +1 -1
- package/src/tui/dist/index.mjs +403 -170
- package/src/tui/hooks/useSession.mjs +16 -0
- package/src/tui/index.jsx +11 -11
- package/src/tui/paste-attachments.mjs +13 -7
- package/src/tui/prompt-history-store.mjs +52 -4
- package/src/tui/{engine → session}/agent-envelope.mjs +3 -3
- package/src/tui/{engine → session}/agent-job-feed.mjs +9 -5
- package/src/tui/{engine → session}/boot-profile.mjs +2 -2
- package/src/tui/{engine → session}/context-state.mjs +3 -3
- package/src/tui/{engine → session}/labels.mjs +2 -2
- package/src/tui/{engine → session}/live-share.mjs +28 -14
- package/src/tui/{engine → session}/notice-text.mjs +2 -2
- package/src/tui/{engine → session}/notification-plan.mjs +3 -3
- package/src/tui/session/oauth-flows.mjs +140 -0
- package/src/tui/{engine → session}/prompt-history.mjs +3 -3
- package/src/tui/{engine → session}/queue-helpers.mjs +8 -8
- package/src/tui/{engine → session}/render-timing.mjs +2 -2
- package/src/tui/{engine → session}/session-api-ext.mjs +42 -21
- package/src/tui/{engine → session}/session-api.mjs +17 -61
- package/src/tui/{engine → session}/session-flow.mjs +5 -3
- package/src/tui/{engine → session}/session-stats.mjs +3 -3
- package/src/tui/{engine → session}/tool-approval.mjs +3 -3
- package/src/tui/{engine → session}/tool-call-fields.mjs +2 -2
- package/src/tui/{engine → session}/tool-card-results.mjs +4 -4
- package/src/tui/{engine → session}/tool-result-status.mjs +5 -5
- package/src/tui/{engine → session}/tool-result-text.mjs +3 -3
- package/src/tui/{engine → session}/transcript-spill.mjs +34 -34
- package/src/tui/{engine → session}/turn.mjs +29 -107
- package/src/tui/{engine-local-session.mjs → session-local.mjs} +72 -78
- package/src/tui/session.mjs +22 -0
- package/src/ui/statusline-segments.mjs +1 -1
- package/src/ui/statusline.mjs +1 -1
- package/vendor/ink/build/components/App.js +10 -1
- package/src/session-runtime/memory-daemon-probe.mjs +0 -61
- package/src/standalone/engine-daemon-protocol.mjs +0 -32
- package/src/tui/engine/turn-watchdog.mjs +0 -92
- package/src/tui/engine.mjs +0 -22
- package/src/tui/hooks/useEngine.mjs +0 -16
- /package/src/runtime/agent/orchestrator/session/compact/{engine.mjs → runner.mjs} +0 -0
- /package/src/runtime/channels/{backends → providers}/discord-attachments.mjs +0 -0
- /package/src/tui/{engine → session}/agent-response-tail.mjs +0 -0
- /package/src/tui/{engine → session}/frame-batched-store.mjs +0 -0
- /package/src/tui/{engine → session}/tui-steering-persist.mjs +0 -0
|
@@ -345,6 +345,8 @@ function normalizedRow(row, heartbeatAt = 0) {
|
|
|
345
345
|
preview: cleanText(row.preview),
|
|
346
346
|
generation: typeof row.generation === 'number' ? row.generation : 0,
|
|
347
347
|
implicitBashSessionId: row.implicitBashSessionId || null,
|
|
348
|
+
storageMtimeMs: positiveNumber(row.storageMtimeMs, 0),
|
|
349
|
+
storageSize: positiveNumber(row.storageSize, 0),
|
|
348
350
|
detachedReason: row.detachedReason || null,
|
|
349
351
|
};
|
|
350
352
|
}
|
|
@@ -412,10 +414,15 @@ function sessionHeartbeatMtimes() {
|
|
|
412
414
|
* Returns `null` when the directory itself is NOT enumerable (unreadable
|
|
413
415
|
* stat / readdir): that is not "no sessions", and the caller must keep the
|
|
414
416
|
* authority it already has (the index rows) instead of publishing an empty
|
|
415
|
-
* catalog. `indexRowsById` carries those rows so
|
|
416
|
-
* file
|
|
417
|
+
* catalog. `indexRowsById` carries those rows so unchanged files can reuse the
|
|
418
|
+
* durable summary and a single UNREADABLE file retains its last known row
|
|
419
|
+
* instead of vanishing.
|
|
417
420
|
*/
|
|
418
|
-
function scanSessionFiles(
|
|
421
|
+
function scanSessionFiles(
|
|
422
|
+
heartbeatMtimes = sessionHeartbeatMtimes(),
|
|
423
|
+
indexRowsById = new Map(),
|
|
424
|
+
{ indexMtimeMs = 0, forceRead = false } = {},
|
|
425
|
+
) {
|
|
419
426
|
const directory = join(dataDir(), 'sessions');
|
|
420
427
|
const dirProbe = probePath(directory);
|
|
421
428
|
if (dirProbe.state === PROBE_ABSENT) return [];
|
|
@@ -426,13 +433,37 @@ function scanSessionFiles(heartbeatMtimes = sessionHeartbeatMtimes(), indexRowsB
|
|
|
426
433
|
for (const filename of entries) {
|
|
427
434
|
if (!filename.endsWith('.json')) continue;
|
|
428
435
|
const storageId = filename.slice(0, -5);
|
|
429
|
-
const
|
|
436
|
+
const path = join(directory, filename);
|
|
437
|
+
const retained = indexRowsById.get(storageId);
|
|
438
|
+
const probe = probePath(path);
|
|
439
|
+
// Provably gone: the row legitimately disappears with the file.
|
|
440
|
+
if (probe.state === PROBE_ABSENT) continue;
|
|
441
|
+
if (probe.state !== PROBE_PRESENT) {
|
|
442
|
+
if (retained) rows.push(retained);
|
|
443
|
+
continue;
|
|
444
|
+
}
|
|
445
|
+
if (!forceRead && retained) {
|
|
446
|
+
const exactFingerprint = retained.storageMtimeMs > 0
|
|
447
|
+
&& retained.storageSize > 0
|
|
448
|
+
&& retained.storageMtimeMs === probe.mtimeMs
|
|
449
|
+
&& retained.storageSize === probe.size;
|
|
450
|
+
// Older v2 rows predate per-file fingerprints. Their global index
|
|
451
|
+
// timestamp remains a one-release compatibility fallback; every
|
|
452
|
+
// later save/rebuild stamps the exact (mtimeMs,size) pair.
|
|
453
|
+
const legacyUnchanged = retained.storageMtimeMs === 0
|
|
454
|
+
&& indexMtimeMs > 0
|
|
455
|
+
&& (probe.mtimeMs || 0) <= indexMtimeMs;
|
|
456
|
+
if (exactFingerprint || legacyUnchanged) {
|
|
457
|
+
rows.push(retained);
|
|
458
|
+
continue;
|
|
459
|
+
}
|
|
460
|
+
}
|
|
461
|
+
const read = readTextFile(path);
|
|
430
462
|
// Provably gone: the row legitimately disappears with the file.
|
|
431
463
|
if (read.state === PROBE_ABSENT) continue;
|
|
432
464
|
if (read.state !== PROBE_PRESENT) {
|
|
433
465
|
// Exists and could not be read (EACCES/EIO): retain the last known
|
|
434
466
|
// row rather than let a transient IO error delete it from view.
|
|
435
|
-
const retained = indexRowsById.get(storageId);
|
|
436
467
|
if (retained) rows.push(retained);
|
|
437
468
|
continue;
|
|
438
469
|
}
|
|
@@ -440,36 +471,16 @@ function scanSessionFiles(heartbeatMtimes = sessionHeartbeatMtimes(), indexRowsB
|
|
|
440
471
|
// record or a foreign identity is not this file's session.
|
|
441
472
|
const record = readTopLevelLifecycleRecord(read.text);
|
|
442
473
|
if (isLifecycleUnreadable(record) || record.id !== storageId) continue;
|
|
443
|
-
const
|
|
474
|
+
const summary = rowFromSession(record.doc, heartbeatMtimes.get(record.id) || 0);
|
|
475
|
+
const row = summary
|
|
476
|
+
? { ...summary, storageMtimeMs: probe.mtimeMs, storageSize: probe.size }
|
|
477
|
+
: null;
|
|
444
478
|
if (row) rows.push(row);
|
|
445
479
|
}
|
|
446
480
|
return leadRowsWithAgentHeartbeat(rows).sort((left, right) =>
|
|
447
481
|
(right.lastUsedAt || right.updatedAt || 0) - (left.lastUsedAt || left.updatedAt || 0));
|
|
448
482
|
}
|
|
449
483
|
|
|
450
|
-
function sessionFilesAreNewerThan(indexPath) {
|
|
451
|
-
const directory = join(dataDir(), 'sessions');
|
|
452
|
-
if (probePath(directory).state !== PROBE_PRESENT) return false;
|
|
453
|
-
const indexProbe = probePath(indexPath);
|
|
454
|
-
// Only a PROVABLY absent index makes the files newer by definition. An
|
|
455
|
-
// index that exists but cannot be stat'ed (EACCES/EIO) is still the
|
|
456
|
-
// authority we failed to consult — claiming "files are newer" there would
|
|
457
|
-
// replace the whole catalog on the strength of an IO error.
|
|
458
|
-
if (indexProbe.state === PROBE_ABSENT) return true;
|
|
459
|
-
if (indexProbe.state !== PROBE_PRESENT) return false;
|
|
460
|
-
const indexMtime = indexProbe.mtimeMs || 0;
|
|
461
|
-
let entries;
|
|
462
|
-
try { entries = readdirSync(directory); } catch { return false; }
|
|
463
|
-
for (const filename of entries) {
|
|
464
|
-
if (!filename.endsWith('.json')) continue;
|
|
465
|
-
// Absent (raced unlink) and unreadable both prove nothing about
|
|
466
|
-
// freshness: only an observed newer mtime does.
|
|
467
|
-
const probe = probePath(join(directory, filename));
|
|
468
|
-
if (probe.state === PROBE_PRESENT && (probe.mtimeMs || 0) > indexMtime) return true;
|
|
469
|
-
}
|
|
470
|
-
return false;
|
|
471
|
-
}
|
|
472
|
-
|
|
473
484
|
export function listStoredSessionSummaries(options = {}) {
|
|
474
485
|
const heartbeatMtimes = sessionHeartbeatMtimes();
|
|
475
486
|
const indexPath = join(dataDir(), 'session-summaries.json');
|
|
@@ -478,6 +489,7 @@ export function listStoredSessionSummaries(options = {}) {
|
|
|
478
489
|
// an individual session file cannot be read.
|
|
479
490
|
const indexRowsById = new Map();
|
|
480
491
|
let indexRows = null;
|
|
492
|
+
let indexMtimeMs = 0;
|
|
481
493
|
const indexRead = readTextFile(indexPath);
|
|
482
494
|
if (indexRead.state === PROBE_PRESENT) {
|
|
483
495
|
try {
|
|
@@ -490,25 +502,30 @@ export function listStoredSessionSummaries(options = {}) {
|
|
|
490
502
|
indexRows = leadRowsWithAgentHeartbeat(normalizedRows)
|
|
491
503
|
.sort((left, right) =>
|
|
492
504
|
(right.lastUsedAt || right.updatedAt || 0) - (left.lastUsedAt || left.updatedAt || 0));
|
|
505
|
+
const indexProbe = probePath(indexPath);
|
|
506
|
+
if (indexProbe.state === PROBE_PRESENT) indexMtimeMs = indexProbe.mtimeMs || 0;
|
|
493
507
|
}
|
|
494
508
|
} catch { /* malformed sidecar: the files below are the authority */ }
|
|
495
509
|
}
|
|
496
510
|
// An index that EXISTS but is unreadable (EACCES/EIO) is neither missing
|
|
497
511
|
// nor empty: the scan below still runs, and when IT cannot enumerate
|
|
498
512
|
// either, the catalog reports nothing rather than inventing an empty truth.
|
|
499
|
-
const scan = () => scanSessionFiles(
|
|
500
|
-
|
|
513
|
+
const scan = (forceRead = false) => scanSessionFiles(
|
|
514
|
+
heartbeatMtimes,
|
|
515
|
+
indexRowsById,
|
|
516
|
+
{ indexMtimeMs, forceRead },
|
|
517
|
+
);
|
|
518
|
+
if (options.refreshFromStorage === true) return scan(true) ?? indexRows ?? [];
|
|
501
519
|
if (indexRows) {
|
|
502
|
-
|
|
503
|
-
//
|
|
504
|
-
//
|
|
505
|
-
//
|
|
506
|
-
|
|
507
|
-
return scan() ?? indexRows;
|
|
508
|
-
}
|
|
509
|
-
if (indexRows.length > 0 || options.rebuildIfMissing === false) return indexRows;
|
|
520
|
+
if (options.rebuildIfMissing === false) return indexRows;
|
|
521
|
+
// Enumerate identities to detect deletions, stat known files, and parse
|
|
522
|
+
// only rows newer than the index. Cold-start cost therefore scales with
|
|
523
|
+
// changed transcripts rather than total transcript bytes.
|
|
524
|
+
return scan(false) ?? indexRows;
|
|
510
525
|
}
|
|
511
|
-
|
|
526
|
+
// Missing/malformed index is the sole cold path that must rebuild every
|
|
527
|
+
// summary from canonical session bytes.
|
|
528
|
+
return options.rebuildIfMissing === false ? [] : (scan(true) ?? indexRows ?? []);
|
|
512
529
|
}
|
|
513
530
|
|
|
514
531
|
/** Exact, fail-closed existence check for a durable session address.
|
|
@@ -567,7 +584,7 @@ export async function readStoredSessionTranscript(id, options = {}) {
|
|
|
567
584
|
restoreTranscriptItems,
|
|
568
585
|
sessionContextSnapshotProjection,
|
|
569
586
|
} = await import(
|
|
570
|
-
'../../../../tui/
|
|
587
|
+
'../../../../tui/session/session-api-ext.mjs'
|
|
571
588
|
);
|
|
572
589
|
let preparedContextProjection = null;
|
|
573
590
|
try {
|
|
@@ -724,7 +741,7 @@ export async function createStoredSessionLiveViewer(id, options = {}) {
|
|
|
724
741
|
clearStreamingTail: () => commit({ streamingTail: null }),
|
|
725
742
|
};
|
|
726
743
|
const { createLiveShare, liveSharePipePath } = await import(
|
|
727
|
-
'../../../../tui/
|
|
744
|
+
'../../../../tui/session/live-share.mjs'
|
|
728
745
|
);
|
|
729
746
|
const share = createLiveShare({
|
|
730
747
|
ownerSessionId: () => '',
|
|
@@ -257,8 +257,9 @@ export async function processToolBatch(ctx) {
|
|
|
257
257
|
_executeOk = true;
|
|
258
258
|
} else {
|
|
259
259
|
// Fallback for providers that don't stream tool calls early:
|
|
260
|
-
//
|
|
261
|
-
//
|
|
260
|
+
// dispatch the whole remaining batch. The eager dispatcher
|
|
261
|
+
// keeps independent calls parallel while making shell wait
|
|
262
|
+
// for earlier apply_patch calls.
|
|
262
263
|
if (isParallelDispatchable(call.name)) {
|
|
263
264
|
startEagerRun(calls, callIndex, _duplicateCallIds);
|
|
264
265
|
}
|
|
@@ -506,38 +506,24 @@ export async function executeBashTool(args, workDir, options = {}) {
|
|
|
506
506
|
{ backgroundTasksDisabled: _bgTasksDisabled },
|
|
507
507
|
);
|
|
508
508
|
if (longForegroundHint) return formatShellToolFailure(longForegroundHint);
|
|
509
|
-
//
|
|
510
|
-
//
|
|
511
|
-
//
|
|
512
|
-
//
|
|
513
|
-
//
|
|
514
|
-
//
|
|
515
|
-
// remains a separate, later bound. Never applies to run_in_background
|
|
516
|
-
// (already detached) or persistent sessions (handled far above).
|
|
517
|
-
// Promotion threshold (Codex `yield_time_ms` / CC startBackgrounding
|
|
518
|
-
// analogue). Previously 0 = OFF, so every long command held the turn open
|
|
519
|
-
// to the full foreground timeout and, at the cap, produced a wasted wait.
|
|
520
|
-
// 30 s keeps ordinary builds/tests inline (measured shell p90 ~ 18 s) while
|
|
521
|
-
// detaching the long tail (measured: 111 calls/day above 30 s, 30 of them
|
|
522
|
-
// burning the entire 120 s cap) as a tracked job with partial output.
|
|
509
|
+
// Main-agent blocking budget. A timeout is the command's total deadline,
|
|
510
|
+
// not permission to hold the conversation open for that whole duration:
|
|
511
|
+
// after 15 s a still-running command becomes a tracked background task and
|
|
512
|
+
// completion is pushed to the owner. Explicit timeouts keep their remaining
|
|
513
|
+
// deadline after promotion. Never applies to run_in_background (already
|
|
514
|
+
// detached), persistent sessions, or commands barred from backgrounding.
|
|
523
515
|
// MIXDOG_SHELL_AUTO_BACKGROUND_MS overrides; an explicit 0 disables.
|
|
524
516
|
const _autoBgEnvRaw = process.env.MIXDOG_SHELL_AUTO_BACKGROUND_MS;
|
|
525
517
|
const _autoBgEnvMs = Number(_autoBgEnvRaw);
|
|
526
518
|
const DEFAULT_AUTO_BACKGROUND_MS = (_autoBgEnvRaw != null && String(_autoBgEnvRaw).trim() !== ''
|
|
527
519
|
&& Number.isFinite(_autoBgEnvMs) && _autoBgEnvMs >= 0)
|
|
528
520
|
? Math.floor(_autoBgEnvMs)
|
|
529
|
-
:
|
|
521
|
+
: 15_000;
|
|
530
522
|
// Gate on backgroundOnTimeout rather than just `runInBackground`: it already
|
|
531
523
|
// encodes "promotion is available for this command" (not detached, background
|
|
532
524
|
// tasks enabled, command shape allows it), so the soft threshold can never
|
|
533
525
|
// promote something the hard timeout would refuse to.
|
|
534
|
-
|
|
535
|
-
// (a long build/test/install), so the soft threshold never overrides it —
|
|
536
|
-
// the call stays inline until its own deadline, where backgroundOnTimeout
|
|
537
|
-
// still promotes instead of killing. Auto-promotion therefore only applies
|
|
538
|
-
// to the OMITTED-timeout default, which is exactly the case where nobody
|
|
539
|
-
// declared how long the command should be allowed to hold the turn.
|
|
540
|
-
const autoBackgroundMs = (!backgroundOnTimeout || hasExplicitTimeout || DEFAULT_AUTO_BACKGROUND_MS <= 0)
|
|
526
|
+
const autoBackgroundMs = (!backgroundOnTimeout || DEFAULT_AUTO_BACKGROUND_MS <= 0)
|
|
541
527
|
? 0
|
|
542
528
|
: Math.min(DEFAULT_AUTO_BACKGROUND_MS, timeout);
|
|
543
529
|
|
|
@@ -712,6 +698,10 @@ export async function executeBashTool(args, workDir, options = {}) {
|
|
|
712
698
|
// tracked background job (unlimited) instead of killing it.
|
|
713
699
|
backgroundOnTimeout,
|
|
714
700
|
promotedTimeoutMs,
|
|
701
|
+
// Soft/interrupt promotion happens before the foreground cap.
|
|
702
|
+
// Preserve an explicit total deadline by passing it separately;
|
|
703
|
+
// omitted-timeout promotions retain the existing unlimited job.
|
|
704
|
+
backgroundDeadlineMs: hasExplicitTimeout ? totalTimeout : 0,
|
|
715
705
|
// Threaded so an auto-backgrounded foreground job is stamped with
|
|
716
706
|
// the dispatching terminal's claude.exe pid (per-terminal scope).
|
|
717
707
|
clientHostPid: options?.clientHostPid,
|
|
@@ -1,17 +1,33 @@
|
|
|
1
1
|
import { closeSync, openSync, readSync } from 'fs';
|
|
2
|
+
import { open } from 'fs/promises';
|
|
3
|
+
|
|
4
|
+
const HEAD_CAP = 64 * 1024;
|
|
5
|
+
const TAIL_SIZE = 4 * 1024;
|
|
6
|
+
|
|
7
|
+
function hasUtf16Bom(buf, length = buf?.length || 0) {
|
|
8
|
+
return length >= 2
|
|
9
|
+
&& ((buf[0] === 0xff && buf[1] === 0xfe)
|
|
10
|
+
|| (buf[0] === 0xfe && buf[1] === 0xff));
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
function containsNull(buf, length = buf?.length || 0) {
|
|
14
|
+
for (let i = 0; i < length; i++) {
|
|
15
|
+
if (buf[i] === 0) return true;
|
|
16
|
+
}
|
|
17
|
+
return false;
|
|
18
|
+
}
|
|
2
19
|
|
|
3
20
|
// Binary detection: reading a PNG / ELF / zip / compressed blob as utf-8
|
|
4
21
|
// pollutes the context with U+FFFD characters and wastes tokens. Sample the
|
|
5
22
|
// head and tail of the file and look for a null byte — the canonical signal
|
|
6
23
|
// that the file is not plain text. Head window scales with file size:
|
|
7
24
|
// min(fileSize, 64KB) head + 4KB tail, so a 250KB file with a null byte at
|
|
8
|
-
// 9KB or 249KB is caught equally.
|
|
9
|
-
//
|
|
25
|
+
// 9KB or 249KB is caught equally. This synchronous form remains for legacy
|
|
26
|
+
// mode readers. The main read path uses inspectBinaryFile(), which performs
|
|
27
|
+
// the same probes asynchronously.
|
|
10
28
|
// Callers inside the ≤READ_MAX_SIZE_BYTES branch should pass st.size so the
|
|
11
29
|
// tail probe fires; callers above the cap pass the real size from err.size.
|
|
12
30
|
export function isBinaryFile(fullPath, fileSize = 0) {
|
|
13
|
-
const HEAD_CAP = 64 * 1024; // 64 KB max head window
|
|
14
|
-
const TAIL_SIZE = 4 * 1024; // 4 KB tail probe
|
|
15
31
|
const headBytes = fileSize > 0 ? Math.min(fileSize, HEAD_CAP) : HEAD_CAP;
|
|
16
32
|
let fd = null;
|
|
17
33
|
try {
|
|
@@ -25,20 +41,14 @@ export function isBinaryFile(fullPath, fileSize = 0) {
|
|
|
25
41
|
// (detectReadEncodingFromBuffer/decodeReadBuffer support utf16le and
|
|
26
42
|
// utf16be), so exempt it rather than reject the file as binary.
|
|
27
43
|
// FF FE = UTF-16LE, FE FF = UTF-16BE.
|
|
28
|
-
if (nHead
|
|
29
|
-
|
|
30
|
-
|| (headBuf[0] === 0xfe && headBuf[1] === 0xff))) return false;
|
|
31
|
-
for (let i = 0; i < nHead; i++) {
|
|
32
|
-
if (headBuf[i] === 0) return true;
|
|
33
|
-
}
|
|
44
|
+
if (hasUtf16Bom(headBuf, nHead)) return false;
|
|
45
|
+
if (containsNull(headBuf, nHead)) return true;
|
|
34
46
|
// Tail probe (only when file is larger than head window)
|
|
35
47
|
if (fileSize > headBytes && fileSize > TAIL_SIZE) {
|
|
36
48
|
const tailOffset = fileSize - TAIL_SIZE;
|
|
37
49
|
const tailBuf = Buffer.allocUnsafe(TAIL_SIZE);
|
|
38
50
|
const nTail = readSync(fd, tailBuf, 0, TAIL_SIZE, tailOffset);
|
|
39
|
-
|
|
40
|
-
if (tailBuf[i] === 0) return true;
|
|
41
|
-
}
|
|
51
|
+
if (containsNull(tailBuf, nTail)) return true;
|
|
42
52
|
}
|
|
43
53
|
return false;
|
|
44
54
|
} catch {
|
|
@@ -48,8 +58,71 @@ export function isBinaryFile(fullPath, fileSize = 0) {
|
|
|
48
58
|
}
|
|
49
59
|
}
|
|
50
60
|
|
|
61
|
+
/** Classify an already-loaded whole file without another open/read cycle. */
|
|
62
|
+
export function isBinaryBuffer(buf, fileSize = buf?.length || 0) {
|
|
63
|
+
if (!Buffer.isBuffer(buf) || buf.length === 0) return false;
|
|
64
|
+
const size = fileSize > 0 ? Math.min(fileSize, buf.length) : buf.length;
|
|
65
|
+
const headBytes = Math.min(size, HEAD_CAP);
|
|
66
|
+
if (hasUtf16Bom(buf, headBytes)) return false;
|
|
67
|
+
if (containsNull(buf, headBytes)) return true;
|
|
68
|
+
if (size > headBytes && size > TAIL_SIZE) {
|
|
69
|
+
const tail = buf.subarray(Math.max(0, size - TAIL_SIZE), size);
|
|
70
|
+
if (containsNull(tail)) return true;
|
|
71
|
+
}
|
|
72
|
+
return false;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* Async binary probe that keeps one file handle for head, tail, and preview.
|
|
77
|
+
* Returns the head bytes even when the null marker is in the tail so callers
|
|
78
|
+
* can render a hex preview without reopening the file.
|
|
79
|
+
*/
|
|
80
|
+
export async function inspectBinaryFile(fullPath, fileSize = 0, { previewBytes = 256 } = {}) {
|
|
81
|
+
const headBytes = fileSize > 0 ? Math.min(fileSize, HEAD_CAP) : HEAD_CAP;
|
|
82
|
+
let fh;
|
|
83
|
+
try {
|
|
84
|
+
fh = await open(fullPath, 'r');
|
|
85
|
+
const headBuf = Buffer.allocUnsafe(Math.max(0, headBytes));
|
|
86
|
+
const { bytesRead: nHead } = await fh.read(headBuf, 0, headBytes, 0);
|
|
87
|
+
const head = headBuf.subarray(0, nHead);
|
|
88
|
+
const preview = head.subarray(0, Math.min(previewBytes, nHead));
|
|
89
|
+
if (nHead === 0 || hasUtf16Bom(head, nHead)) return { isBinary: false, preview };
|
|
90
|
+
if (containsNull(head, nHead)) return { isBinary: true, preview };
|
|
91
|
+
if (fileSize > headBytes && fileSize > TAIL_SIZE) {
|
|
92
|
+
const tailBuf = Buffer.allocUnsafe(TAIL_SIZE);
|
|
93
|
+
const { bytesRead: nTail } = await fh.read(
|
|
94
|
+
tailBuf,
|
|
95
|
+
0,
|
|
96
|
+
TAIL_SIZE,
|
|
97
|
+
fileSize - TAIL_SIZE,
|
|
98
|
+
);
|
|
99
|
+
if (containsNull(tailBuf, nTail)) return { isBinary: true, preview };
|
|
100
|
+
}
|
|
101
|
+
return { isBinary: false, preview };
|
|
102
|
+
} catch {
|
|
103
|
+
return { isBinary: false, preview: Buffer.alloc(0) };
|
|
104
|
+
} finally {
|
|
105
|
+
if (fh) { try { await fh.close(); } catch {} }
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
|
|
51
109
|
const BINARY_PREVIEW_BYTES = 256;
|
|
52
110
|
|
|
111
|
+
export function formatBinaryReadPreviewFromBuffer(
|
|
112
|
+
buffer,
|
|
113
|
+
displayPath,
|
|
114
|
+
fileSize,
|
|
115
|
+
{ previewBytes = BINARY_PREVIEW_BYTES } = {},
|
|
116
|
+
) {
|
|
117
|
+
const source = Buffer.isBuffer(buffer) ? buffer : Buffer.alloc(0);
|
|
118
|
+
const slice = source.subarray(0, Math.min(previewBytes, source.length));
|
|
119
|
+
const hex = Array.from(slice).map((b) => b.toString(16).padStart(2, '0')).join(' ');
|
|
120
|
+
const disp = displayPath || '(binary file)';
|
|
121
|
+
const note = `binary, ${fileSize} byte${fileSize === 1 ? '' : 's'}`;
|
|
122
|
+
const text = `${disp}\n${note}\n${hex || '(empty)'}`;
|
|
123
|
+
return { text, snapshotMeta: { source: 'read_hex', ranges: [] } };
|
|
124
|
+
}
|
|
125
|
+
|
|
53
126
|
/** Short hex preview for read when null bytes mark the file as binary. */
|
|
54
127
|
export function formatBinaryReadPreview(fullPath, displayPath, fileSize, { previewBytes = BINARY_PREVIEW_BYTES } = {}) {
|
|
55
128
|
const n = Math.max(0, Math.min(previewBytes, fileSize > 0 ? fileSize : previewBytes));
|
|
@@ -58,12 +131,12 @@ export function formatBinaryReadPreview(fullPath, displayPath, fileSize, { previ
|
|
|
58
131
|
fd = openSync(fullPath, 'r');
|
|
59
132
|
const buf = Buffer.alloc(n);
|
|
60
133
|
const bytesRead = readSync(fd, buf, 0, n, 0);
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
134
|
+
return formatBinaryReadPreviewFromBuffer(
|
|
135
|
+
buf.subarray(0, bytesRead),
|
|
136
|
+
displayPath || fullPath,
|
|
137
|
+
fileSize,
|
|
138
|
+
{ previewBytes },
|
|
139
|
+
);
|
|
67
140
|
} catch {
|
|
68
141
|
const disp = displayPath || fullPath;
|
|
69
142
|
return {
|
|
@@ -47,6 +47,6 @@ export async function assertPathReachable(path, deadlineMs = FS_REACHABILITY_DEA
|
|
|
47
47
|
// the wall-clock cost is one deadline, not N.
|
|
48
48
|
export async function assertPathsReachable(paths, deadlineMs = FS_REACHABILITY_DEADLINE_MS) {
|
|
49
49
|
const list = Array.isArray(paths) ? paths.filter((p) => typeof p === 'string' && p.length) : [];
|
|
50
|
-
if (list.length === 0) return;
|
|
51
|
-
await Promise.all(list.map((p) => assertPathReachable(p, deadlineMs)));
|
|
50
|
+
if (list.length === 0) return [];
|
|
51
|
+
return await Promise.all(list.map((p) => assertPathReachable(p, deadlineMs)));
|
|
52
52
|
}
|
|
@@ -18,10 +18,12 @@ import {
|
|
|
18
18
|
cacheGet,
|
|
19
19
|
cacheSet,
|
|
20
20
|
getCachedReadOnlyStat,
|
|
21
|
+
lstatPathsForMtime,
|
|
21
22
|
statCacheSet,
|
|
22
23
|
statPathsForMtime,
|
|
23
24
|
registerCacheInvalidationListener,
|
|
24
25
|
} from './cache-layers.mjs';
|
|
26
|
+
import { formatListSize, formatMtime } from './list-formatting.mjs';
|
|
25
27
|
import {
|
|
26
28
|
compileSimpleGlob,
|
|
27
29
|
NOISE_DIR_NAMES,
|
|
@@ -3,10 +3,11 @@
|
|
|
3
3
|
// readImageWithTokenBudget) so a `read` on an image returns a viewable,
|
|
4
4
|
// budget-bounded image block instead of refusing oversized originals.
|
|
5
5
|
//
|
|
6
|
-
// sharp is
|
|
7
|
-
//
|
|
8
|
-
//
|
|
9
|
-
|
|
6
|
+
// sharp is a direct runtime dependency. Entry points still degrade to `null`
|
|
7
|
+
// when a platform-native binding cannot load so a damaged install reports the
|
|
8
|
+
// existing bounded fallback instead of crashing the whole daemon.
|
|
9
|
+
|
|
10
|
+
import { createHash } from 'node:crypto';
|
|
10
11
|
|
|
11
12
|
// Anthropic inline-image input is capped near 5MB base64 (API rejects on the
|
|
12
13
|
// base64 LENGTH, not raw bytes). IMAGE_TARGET_RAW_SIZE is the raw-byte target
|
|
@@ -20,6 +21,70 @@ const IMAGE_MAX_HEIGHT = 2000;
|
|
|
20
21
|
// dimension/raw-size resize governs the common case and the token gate only
|
|
21
22
|
// fires on pathologically dense images.
|
|
22
23
|
const DEFAULT_IMAGE_MAX_TOKENS = Math.ceil(API_IMAGE_MAX_BASE64_SIZE * 0.125);
|
|
24
|
+
export const OPENAI_IMAGE_MAX_DIMENSION = 2048;
|
|
25
|
+
export const OPENAI_IMAGE_PATCH_SIZE = 32;
|
|
26
|
+
export const OPENAI_IMAGE_MAX_PATCHES = 1536;
|
|
27
|
+
const IMAGE_RESIZE_CACHE_MAX_BYTES = 64 * 1024 * 1024;
|
|
28
|
+
const imageResizeCache = new Map();
|
|
29
|
+
let imageResizeCacheBytes = 0;
|
|
30
|
+
let imageResizeCacheHits = 0;
|
|
31
|
+
let imageResizeCacheMisses = 0;
|
|
32
|
+
|
|
33
|
+
export function imageProfileForProvider(provider) {
|
|
34
|
+
const value = String(provider || '').trim().toLowerCase();
|
|
35
|
+
return /^(?:openai|xai|grok|deepseek|opencode-go|ollama|lmstudio)(?:-|$)/.test(value)
|
|
36
|
+
? 'openai'
|
|
37
|
+
: 'anthropic';
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export function openAIImagePatchCount(width, height) {
|
|
41
|
+
return Math.ceil(Math.max(1, Number(width) || 1) / OPENAI_IMAGE_PATCH_SIZE)
|
|
42
|
+
* Math.ceil(Math.max(1, Number(height) || 1) / OPENAI_IMAGE_PATCH_SIZE);
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
function resizeCacheKey(buffer, ext, maxTokens, profile) {
|
|
46
|
+
return createHash('sha256')
|
|
47
|
+
.update(String(ext || '')).update('\0')
|
|
48
|
+
.update(String(maxTokens || 0)).update('\0')
|
|
49
|
+
.update(String(profile || '')).update('\0')
|
|
50
|
+
.update(buffer)
|
|
51
|
+
.digest('hex');
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
function cloneResizeResult(result) {
|
|
55
|
+
return {
|
|
56
|
+
...result,
|
|
57
|
+
...(result?.dimensions ? { dimensions: { ...result.dimensions } } : {}),
|
|
58
|
+
};
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
function rememberResizeResult(key, result) {
|
|
62
|
+
const bytes = Buffer.byteLength(String(result?.data || ''), 'base64');
|
|
63
|
+
if (bytes <= 0 || bytes > IMAGE_RESIZE_CACHE_MAX_BYTES) return;
|
|
64
|
+
const existing = imageResizeCache.get(key);
|
|
65
|
+
if (existing) {
|
|
66
|
+
imageResizeCacheBytes -= existing.bytes;
|
|
67
|
+
imageResizeCache.delete(key);
|
|
68
|
+
}
|
|
69
|
+
imageResizeCache.set(key, { result: cloneResizeResult(result), bytes });
|
|
70
|
+
imageResizeCacheBytes += bytes;
|
|
71
|
+
while (imageResizeCacheBytes > IMAGE_RESIZE_CACHE_MAX_BYTES && imageResizeCache.size > 0) {
|
|
72
|
+
const oldest = imageResizeCache.keys().next().value;
|
|
73
|
+
const evicted = imageResizeCache.get(oldest);
|
|
74
|
+
imageResizeCache.delete(oldest);
|
|
75
|
+
imageResizeCacheBytes -= evicted?.bytes || 0;
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
export function imageResizeCacheStats() {
|
|
80
|
+
return {
|
|
81
|
+
entries: imageResizeCache.size,
|
|
82
|
+
bytes: imageResizeCacheBytes,
|
|
83
|
+
maxBytes: IMAGE_RESIZE_CACHE_MAX_BYTES,
|
|
84
|
+
hits: imageResizeCacheHits,
|
|
85
|
+
misses: imageResizeCacheMisses,
|
|
86
|
+
};
|
|
87
|
+
}
|
|
23
88
|
|
|
24
89
|
// Cached dynamic import. Resolves to the sharp factory or null (absent /
|
|
25
90
|
// failed). Cached so repeated reads don't re-attempt a failing import.
|
|
@@ -38,6 +103,10 @@ async function loadSharp() {
|
|
|
38
103
|
return _sharpPromise;
|
|
39
104
|
}
|
|
40
105
|
|
|
106
|
+
export function prewarmImageResizer() {
|
|
107
|
+
return loadSharp();
|
|
108
|
+
}
|
|
109
|
+
|
|
41
110
|
// True when sharp resolved; used for the per-file change summary / fallback note.
|
|
42
111
|
async function sharpAvailable() {
|
|
43
112
|
return (await loadSharp()) !== null;
|
|
@@ -88,8 +157,21 @@ export function imageMetadataText(dims, sourcePath) {
|
|
|
88
157
|
// Returns { data (base64), mimeType ("image/..."), dimensions } on success,
|
|
89
158
|
// or null when sharp is unavailable OR any sharp op threw (caller falls back
|
|
90
159
|
// to legacy pass-through-with-cap).
|
|
91
|
-
export async function resizeImageBuffer(buffer, ext, {
|
|
160
|
+
export async function resizeImageBuffer(buffer, ext, {
|
|
161
|
+
maxTokens = DEFAULT_IMAGE_MAX_TOKENS,
|
|
162
|
+
profile = 'anthropic',
|
|
163
|
+
} = {}) {
|
|
92
164
|
if (!Buffer.isBuffer(buffer) || buffer.length === 0) return null;
|
|
165
|
+
const normalizedProfile = profile === 'openai' ? 'openai' : 'anthropic';
|
|
166
|
+
const cacheKey = resizeCacheKey(buffer, ext, maxTokens, normalizedProfile);
|
|
167
|
+
const cached = imageResizeCache.get(cacheKey);
|
|
168
|
+
if (cached) {
|
|
169
|
+
imageResizeCacheHits += 1;
|
|
170
|
+
imageResizeCache.delete(cacheKey);
|
|
171
|
+
imageResizeCache.set(cacheKey, cached);
|
|
172
|
+
return cloneResizeResult(cached.result);
|
|
173
|
+
}
|
|
174
|
+
imageResizeCacheMisses += 1;
|
|
93
175
|
const sharp = await loadSharp();
|
|
94
176
|
if (!sharp) return null;
|
|
95
177
|
try {
|
|
@@ -108,13 +190,20 @@ export async function resizeImageBuffer(buffer, ext, { maxTokens = DEFAULT_IMAGE
|
|
|
108
190
|
// Constrain dimensions while preserving aspect ratio.
|
|
109
191
|
let width = originalWidth;
|
|
110
192
|
let height = originalHeight;
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
193
|
+
const maxWidth = normalizedProfile === 'openai' ? OPENAI_IMAGE_MAX_DIMENSION : IMAGE_MAX_WIDTH;
|
|
194
|
+
const maxHeight = normalizedProfile === 'openai' ? OPENAI_IMAGE_MAX_DIMENSION : IMAGE_MAX_HEIGHT;
|
|
195
|
+
let scale = Math.min(1, maxWidth / width, maxHeight / height);
|
|
196
|
+
if (normalizedProfile === 'openai') {
|
|
197
|
+
scale = Math.min(
|
|
198
|
+
scale,
|
|
199
|
+
Math.sqrt((OPENAI_IMAGE_MAX_PATCHES * OPENAI_IMAGE_PATCH_SIZE ** 2) / (width * height)),
|
|
200
|
+
);
|
|
114
201
|
}
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
202
|
+
width = Math.max(1, Math.floor(width * scale));
|
|
203
|
+
height = Math.max(1, Math.floor(height * scale));
|
|
204
|
+
while (normalizedProfile === 'openai' && openAIImagePatchCount(width, height) > OPENAI_IMAGE_MAX_PATCHES) {
|
|
205
|
+
if (width >= height) width -= 1;
|
|
206
|
+
else height -= 1;
|
|
118
207
|
}
|
|
119
208
|
const needsResize = width !== originalWidth || height !== originalHeight;
|
|
120
209
|
if (needsResize || originalSize > IMAGE_TARGET_RAW_SIZE) {
|
|
@@ -162,11 +251,13 @@ export async function resizeImageBuffer(buffer, ext, { maxTokens = DEFAULT_IMAGE
|
|
|
162
251
|
}
|
|
163
252
|
}
|
|
164
253
|
|
|
165
|
-
|
|
254
|
+
const result = {
|
|
166
255
|
data: base64,
|
|
167
256
|
mimeType: `image/${mediaType}`,
|
|
168
257
|
dimensions: { originalWidth, originalHeight, displayWidth, displayHeight },
|
|
169
258
|
};
|
|
259
|
+
rememberResizeResult(cacheKey, result);
|
|
260
|
+
return result;
|
|
170
261
|
} catch {
|
|
171
262
|
// sharp present but processing failed (corrupt header, unsupported
|
|
172
263
|
// format, OOM). Signal fallback rather than throwing.
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { readFile, stat } from 'fs/promises';
|
|
2
2
|
import { extname } from 'path';
|
|
3
3
|
import {
|
|
4
4
|
API_IMAGE_MAX_BASE64_SIZE,
|
|
@@ -36,14 +36,14 @@ export function imageMimeForPath(p) {
|
|
|
36
36
|
// sharp absent / resize failed: GRACEFUL fallback to the legacy
|
|
37
37
|
// pass-through-with-cap behaviour (refuse over-cap originals with an isError
|
|
38
38
|
// text result, else emit the raw base64 image block). No throw on missing sharp.
|
|
39
|
-
export async function readImageAsContent(fullPath, displayPath) {
|
|
39
|
+
export async function readImageAsContent(fullPath, displayPath, preflightStat = null) {
|
|
40
40
|
const mimeType = imageMimeForPath(fullPath);
|
|
41
41
|
if (!mimeType) return null;
|
|
42
42
|
let st;
|
|
43
|
-
try { st =
|
|
43
|
+
try { st = preflightStat || await stat(fullPath); } catch { return null; }
|
|
44
44
|
|
|
45
45
|
let buf;
|
|
46
|
-
try { buf =
|
|
46
|
+
try { buf = await readFile(fullPath); } catch { return null; }
|
|
47
47
|
if (buf.length === 0) {
|
|
48
48
|
return {
|
|
49
49
|
content: [{ type: 'text', text: `Error: image "${displayPath}" is empty (0 bytes).` }],
|