dsh-ntr 0.3.2 → 0.3.4

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/src/impl.ts CHANGED
@@ -867,6 +867,48 @@ export function childEnv(parent: NodeJS.ProcessEnv, extra: Record<string, string
867
867
 
868
868
  // ---- Model catalog (pure parsing of ACP `configOptions`) ----
869
869
 
870
+ /**
871
+ * ACP categories are free-form UX hints, not identity. Qoder CN tags
872
+ * `reasoning_effort` as `category: 'model'` (the spec means `thought_level`),
873
+ * and nothing stops an agent from reusing a category for several options.
874
+ * Slots are therefore assigned by `configId` first and category second, and a
875
+ * same-category option NEVER replaces another option's slot: whatever loses
876
+ * stays reachable through `extras` instead of silently becoming the model list.
877
+ */
878
+ const EFFORT_OPTION_HINT = /effort|thinking|thought|reason/i
879
+
880
+ function isModelSelector(configId: string, name: string): boolean {
881
+ return configId === 'model' || name.trim().toLowerCase() === 'model'
882
+ }
883
+
884
+ type CatalogSlot = 'model' | 'thought' | 'mode'
885
+
886
+ interface CatalogSlotCandidate {
887
+ slot: CatalogSlot
888
+ rank: number
889
+ option: AcpCatalogOption
890
+ }
891
+
892
+ function catalogSlotFor(category: string | undefined, configId: string, name: string): Omit<CatalogSlotCandidate, 'option'> | undefined {
893
+ const effortish = EFFORT_OPTION_HINT.test(configId) || EFFORT_OPTION_HINT.test(name)
894
+ if (category === 'model') {
895
+ // The option explicitly named `model` is the selector even when other
896
+ // model-scoped options are present.
897
+ if (isModelSelector(configId, name)) return { slot: 'model', rank: 3 }
898
+ // A model-scoped effort is mislabelled, not a model: route it to the
899
+ // thought chip when no real `thought_level` option is published.
900
+ if (effortish) return { slot: 'thought', rank: 1 }
901
+ // An unknown model-scoped select: a last-resort selector only.
902
+ return { slot: 'model', rank: 1 }
903
+ }
904
+ if (category === 'thought_level') return { slot: 'thought', rank: 3 }
905
+ if (category === 'mode') return { slot: 'mode', rank: 2 }
906
+ // `model_config` is where the spec puts model-dependent knobs (effort,
907
+ // context size); only the effort-shaped ones have a dedicated control.
908
+ if (category === 'model_config' && effortish) return { slot: 'thought', rank: 1 }
909
+ return undefined
910
+ }
911
+
870
912
  /**
871
913
  * Extract select options from a raw ACP `configOptions` payload.
872
914
  *
@@ -880,24 +922,19 @@ export function childEnv(parent: NodeJS.ProcessEnv, extra: Record<string, string
880
922
  export function parseCatalogOptions(configOptions: unknown): AcpAgentCatalog {
881
923
  const out: AcpAgentCatalog = {}
882
924
  if (!Array.isArray(configOptions)) return out
883
- const extras = new Map<string, AcpCatalogOption>()
925
+ const definitions: AcpCatalogOption[] = []
926
+ const candidates: CatalogSlotCandidate[] = []
884
927
  for (const raw of configOptions) {
885
928
  if (typeof raw !== 'object' || raw === null) continue
886
929
  const row = raw as Record<string, unknown>
887
930
  const category = typeof row.category === 'string' ? row.category : undefined
888
- const slot = category === 'model'
889
- ? 'model'
890
- : category === 'thought_level'
891
- ? 'thought'
892
- : category === 'mode'
893
- ? 'mode'
894
- : undefined
895
931
  const configIdRaw = row.configId ?? row.id
896
932
  if (typeof configIdRaw !== 'string' && typeof configIdRaw !== 'number') continue
897
933
  const configId = String(configIdRaw)
898
934
  if (configId === '') continue
899
935
  const choices: NonNullable<AcpAgentCatalog['model']>['choices'] = []
900
936
  collectCatalogChoices(row.options, choices)
937
+ const name = typeof row.name === 'string' && row.name !== '' ? row.name : configId
901
938
  // A boolean toggle has no `options` at all; its current value is a JSON
902
939
  // boolean. It is encoded as 'true' / 'false' so the catalog stays
903
940
  // string-typed for every existing reader, and carries `kind` so the
@@ -906,7 +943,7 @@ export function parseCatalogOptions(configOptions: unknown): AcpAgentCatalog {
906
943
  || (typeof row.currentValue === 'boolean' && choices.length === 0)
907
944
  const option: AcpCatalogOption = {
908
945
  configId,
909
- name: typeof row.name === 'string' && row.name !== '' ? row.name : configId,
946
+ name,
910
947
  ...(booleanKind ? { kind: 'boolean' as const } : {}),
911
948
  currentValue: booleanKind
912
949
  ? row.currentValue === true || row.currentValue === 'true'
@@ -915,21 +952,69 @@ export function parseCatalogOptions(configOptions: unknown): AcpAgentCatalog {
915
952
  : typeof row.currentValue === 'string' && row.currentValue !== '' ? row.currentValue : null,
916
953
  choices,
917
954
  }
955
+ definitions.push(option)
918
956
  // A toggle is never a model / thought / mode SELECT: those controls pick
919
957
  // among choices. It belongs to the advanced base, where it is a checkbox.
920
- if (slot !== undefined && !booleanKind) {
921
- out[slot] = option
922
- continue
958
+ if (booleanKind) continue
959
+ const slot = catalogSlotFor(category, configId, name)
960
+ if (slot !== undefined) candidates.push({ ...slot, option })
961
+ }
962
+ const winnerIds = new Set<string>()
963
+ for (const slot of ['model', 'thought', 'mode'] as const) {
964
+ let winner: CatalogSlotCandidate | undefined
965
+ for (const candidate of candidates) {
966
+ if (candidate.slot !== slot) continue
967
+ if (winner === undefined || candidate.rank > winner.rank) {
968
+ winner = candidate
969
+ } else if (candidate.rank === winner.rank && candidate.option.configId === winner.option.configId) {
970
+ // The same option redefined later in one answer: the last definition
971
+ // is the current one.
972
+ winner = candidate
973
+ }
923
974
  }
924
- // Choice-less options have nothing to pick; they would render an empty
925
- // section in the advanced menu, so they stay out of the catalog entirely —
926
- // except a boolean, whose whole point is that it has no choices.
927
- if (choices.length > 0 || booleanKind) extras.set(configId, option)
975
+ if (winner === undefined) continue
976
+ out[slot] = winner.option
977
+ winnerIds.add(winner.option.configId)
978
+ }
979
+ // Choice-less options have nothing to pick; they would render an empty
980
+ // section in the advanced menu, so they stay out of the catalog entirely —
981
+ // except a boolean, whose whole point is that it has no choices. Anything
982
+ // that lost a slot race is still a real, settable option: keep it here.
983
+ const extras = new Map<string, AcpCatalogOption>()
984
+ for (const option of definitions) {
985
+ if (winnerIds.has(option.configId)) continue
986
+ if (option.choices.length > 0 || option.kind === 'boolean') extras.set(option.configId, option)
928
987
  }
929
988
  if (extras.size > 0) out.extras = [...extras.values()]
930
989
  return out
931
990
  }
932
991
 
992
+ /**
993
+ * Whether a payload names ONE model selector without relying on category
994
+ * alone. When an agent publishes several `category: 'model'` options and none
995
+ * is explicitly the `model` id, the category is not trustworthy enough to
996
+ * reject the user's pick — callers must fail open instead of reporting
997
+ * `model_unavailable`.
998
+ */
999
+ export function hasUnambiguousModelSelector(configOptions: unknown): boolean {
1000
+ if (!Array.isArray(configOptions)) return true
1001
+ let scoped = 0
1002
+ let explicit = false
1003
+ for (const raw of configOptions) {
1004
+ if (typeof raw !== 'object' || raw === null) continue
1005
+ const row = raw as Record<string, unknown>
1006
+ if (row.category !== 'model') continue
1007
+ const configIdRaw = row.configId ?? row.id
1008
+ if (typeof configIdRaw !== 'string' && typeof configIdRaw !== 'number') continue
1009
+ const configId = String(configIdRaw)
1010
+ if (configId === '') continue
1011
+ scoped += 1
1012
+ const name = typeof row.name === 'string' ? row.name : ''
1013
+ if (isModelSelector(configId, name)) explicit = true
1014
+ }
1015
+ return explicit || scoped <= 1
1016
+ }
1017
+
933
1018
  /**
934
1019
  * Fold one per-session option answer's SHAPE into an agent-level catalog.
935
1020
  *
@@ -1387,8 +1472,1053 @@ export function describeUnhandledUpdate(kind: string, update: Record<string, unk
1387
1472
  * injected context too (goal/subagent/skill material), and that turn carries no
1388
1473
  * user intent to send anywhere.
1389
1474
  */
1475
+ export interface PromptImageContent {
1476
+ type: 'image'
1477
+ data: string
1478
+ mimeType: string
1479
+ uri?: string
1480
+ }
1481
+
1482
+ export interface PromptTextContent {
1483
+ type: 'text'
1484
+ text: string
1485
+ }
1486
+
1487
+ export type PromptContentBlock = PromptTextContent | PromptImageContent
1488
+
1489
+ /** Helper: guess MIME type from file extension or URL */
1490
+ export function guessImageMimeType(pathOrUrl?: string): string | undefined {
1491
+ if (!pathOrUrl) return undefined
1492
+ const clean = pathOrUrl.split('?')[0].split('#')[0].toLowerCase()
1493
+ if (clean.endsWith('.jpg') || clean.endsWith('.jpeg')) return 'image/jpeg'
1494
+ if (clean.endsWith('.png')) return 'image/png'
1495
+ if (clean.endsWith('.gif')) return 'image/gif'
1496
+ if (clean.endsWith('.webp')) return 'image/webp'
1497
+ if (clean.endsWith('.svg') || clean.endsWith('.svgz')) return 'image/svg+xml'
1498
+ if (clean.endsWith('.bmp')) return 'image/bmp'
1499
+ if (clean.endsWith('.ico')) return 'image/x-icon'
1500
+ if (clean.endsWith('.tiff') || clean.endsWith('.tif')) return 'image/tiff'
1501
+ if (clean.endsWith('.avif')) return 'image/avif'
1502
+ if (clean.endsWith('.heic')) return 'image/heic'
1503
+ if (clean.endsWith('.heif')) return 'image/heif'
1504
+ if (clean.endsWith('.apng')) return 'image/apng'
1505
+ return undefined
1506
+ }
1507
+
1508
+ /** Helper: parse Data URI */
1509
+ export function parseImageDataUrl(url: string): { data: string; mimeType: string } | undefined {
1510
+ if (!url.startsWith('data:')) return undefined
1511
+ const commaIdx = url.indexOf(',')
1512
+ if (commaIdx === -1) return undefined
1513
+ const header = url.slice(5, commaIdx)
1514
+ const raw = url.slice(commaIdx + 1).trim()
1515
+ const parts = header.split(';')
1516
+ const mimeType = parts[0] && parts[0].trim() !== '' ? parts[0].trim().toLowerCase() : 'image/png'
1517
+ if (!mimeType.startsWith('image/')) return undefined
1518
+ const isBase64 = parts.some(p => p.trim() === 'base64')
1519
+ let data: string
1520
+ if (isBase64) {
1521
+ data = raw.replace(/\s+/g, '')
1522
+ } else {
1523
+ try {
1524
+ const decoded = decodeURIComponent(raw)
1525
+ data = typeof Buffer !== 'undefined' ? Buffer.from(decoded).toString('base64') : btoa(decoded)
1526
+ } catch {
1527
+ data = typeof Buffer !== 'undefined' ? Buffer.from(raw).toString('base64') : btoa(raw)
1528
+ }
1529
+ }
1530
+ return { mimeType, data }
1531
+ }
1532
+
1533
+ export function isLocalFilePath(str: string): boolean {
1534
+ if (typeof str !== 'string' || str === '') return false
1535
+ return isAbsolutePath(str) || str.startsWith('./') || str.startsWith('.\\') || str.startsWith('../') || str.startsWith('..\\')
1536
+ }
1537
+
1538
+ export function normalizeFileUrlToPath(uri: string): string {
1539
+ if (!uri.startsWith('file://')) return uri
1540
+ let raw = uri.slice('file://'.length)
1541
+ try {
1542
+ raw = decodeURIComponent(raw)
1543
+ } catch {}
1544
+ if (raw.startsWith('localhost/')) {
1545
+ raw = raw.slice('localhost'.length)
1546
+ }
1547
+ if (/^\/?[a-zA-Z]:/.test(raw)) {
1548
+ return raw.startsWith('/') ? raw.slice(1) : raw
1549
+ }
1550
+ if (raw.startsWith('/')) {
1551
+ return raw
1552
+ }
1553
+ return `//${raw}`
1554
+ }
1555
+
1556
+ function expandHomePath(p: string): string {
1557
+ if (p === '~' || p.startsWith('~/') || p.startsWith('~\\')) {
1558
+ if (typeof process !== 'undefined' && process.env) {
1559
+ const env = process.env
1560
+ const home = env.HOME || env.USERPROFILE || (env.HOMEDRIVE && env.HOMEPATH ? `${env.HOMEDRIVE}${env.HOMEPATH}` : undefined)
1561
+ if (home) {
1562
+ const cleanHome = home.replaceAll('\\', '/').replace(/\/+$/, '')
1563
+ return p === '~' ? cleanHome : `${cleanHome}/${p.slice(2).replaceAll('\\', '/')}`
1564
+ }
1565
+ }
1566
+ }
1567
+ return p
1568
+ }
1569
+
1570
+ function readImageFileFromContext(filePath: string, context?: unknown): Buffer | Uint8Array | string | undefined {
1571
+ const target = normalizeFileUrlToPath(filePath)
1572
+ const expanded = expandHomePath(target)
1573
+ const pathsToTry = target === expanded ? [target] : [target, expanded]
1574
+
1575
+ const builtinFs = typeof process !== 'undefined' && typeof (process as unknown as { getBuiltinModule?: (m: string) => unknown }).getBuiltinModule === 'function'
1576
+ ? ((process as unknown as { getBuiltinModule: (m: string) => unknown }).getBuiltinModule('fs') as { existsSync?: (p: string) => boolean; readFileSync?: (p: string) => Buffer } | null)
1577
+ : null
1578
+
1579
+ for (const p of pathsToTry) {
1580
+ if (context && typeof (context as { readFile?: unknown }).readFile === 'function') {
1581
+ try {
1582
+ const res = (context as { readFile: (path: string) => unknown }).readFile(p)
1583
+ if (res !== undefined) return res as Buffer | Uint8Array | string
1584
+ } catch {}
1585
+ }
1586
+ if (context && typeof (context as { fs?: { readFileSync?: unknown } }).fs?.readFileSync === 'function') {
1587
+ try {
1588
+ const res = (context as { fs: { readFileSync: (path: string) => unknown } }).fs.readFileSync(p)
1589
+ if (res !== undefined) return res as Buffer | Uint8Array | string
1590
+ } catch {}
1591
+ }
1592
+ if (builtinFs && typeof builtinFs.existsSync === 'function' && typeof builtinFs.readFileSync === 'function') {
1593
+ try {
1594
+ if (builtinFs.existsSync(p)) return builtinFs.readFileSync(p)
1595
+ } catch {}
1596
+ }
1597
+ }
1598
+ return undefined
1599
+ }
1600
+
1601
+ /**
1602
+ * Detect image MIME type by examining binary magic numbers.
1603
+ * Supports Buffer, Uint8Array, and base64 strings.
1604
+ */
1605
+ export function detectImageMimeFromMagic(raw: Buffer | Uint8Array | string | undefined): string | undefined {
1606
+ if (!raw) return undefined
1607
+ let bytes: Uint8Array | undefined
1608
+ if (typeof raw === 'string') {
1609
+ const trimmed = raw.trim()
1610
+ if (trimmed.startsWith('<svg') || (trimmed.startsWith('<?xml') && trimmed.includes('<svg'))) {
1611
+ return 'image/svg+xml'
1612
+ }
1613
+ try {
1614
+ const prefix = trimmed.slice(0, 64).replace(/\s+/g, '')
1615
+ if (typeof Buffer !== 'undefined') {
1616
+ bytes = Buffer.from(prefix, 'base64')
1617
+ } else if (typeof atob !== 'undefined') {
1618
+ const bin = atob(prefix)
1619
+ bytes = new Uint8Array(bin.length)
1620
+ for (let i = 0; i < bin.length; i++) bytes[i] = bin.charCodeAt(i)
1621
+ }
1622
+ } catch {
1623
+ return undefined
1624
+ }
1625
+ } else if (raw instanceof Uint8Array || (typeof Buffer !== 'undefined' && Buffer.isBuffer(raw))) {
1626
+ bytes = raw instanceof Uint8Array ? raw : new Uint8Array(raw)
1627
+ }
1628
+
1629
+ if (!bytes || bytes.length < 3) return undefined
1630
+
1631
+ // PNG: 89 50 4E 47 0D 0A 1A 0A
1632
+ if (bytes.length >= 8 &&
1633
+ bytes[0] === 0x89 && bytes[1] === 0x50 && bytes[2] === 0x4e && bytes[3] === 0x47 &&
1634
+ bytes[4] === 0x0d && bytes[5] === 0x0a && bytes[6] === 0x1a && bytes[7] === 0x0a) {
1635
+ return 'image/png'
1636
+ }
1637
+
1638
+ // JPEG: FF D8 FF
1639
+ if (bytes[0] === 0xff && bytes[1] === 0xd8 && bytes[2] === 0xff) {
1640
+ return 'image/jpeg'
1641
+ }
1642
+
1643
+ // GIF: GIF87a or GIF89a (47 49 46 38)
1644
+ if (bytes.length >= 6 &&
1645
+ bytes[0] === 0x47 && bytes[1] === 0x49 && bytes[2] === 0x46 && bytes[3] === 0x38 &&
1646
+ (bytes[4] === 0x37 || bytes[4] === 0x39) && bytes[5] === 0x61) {
1647
+ return 'image/gif'
1648
+ }
1649
+
1650
+ // WebP: RIFF....WEBP (52 49 46 46 .... 57 45 42 50)
1651
+ if (bytes.length >= 12 &&
1652
+ bytes[0] === 0x52 && bytes[1] === 0x49 && bytes[2] === 0x46 && bytes[3] === 0x46 &&
1653
+ bytes[8] === 0x57 && bytes[9] === 0x45 && bytes[10] === 0x42 && bytes[11] === 0x50) {
1654
+ return 'image/webp'
1655
+ }
1656
+
1657
+ // BMP: 42 4D ("BM")
1658
+ if (bytes.length >= 2 && bytes[0] === 0x42 && bytes[1] === 0x4d) {
1659
+ return 'image/bmp'
1660
+ }
1661
+
1662
+ // ICO: 00 00 01 00
1663
+ if (bytes.length >= 4 && bytes[0] === 0x00 && bytes[1] === 0x00 && bytes[2] === 0x01 && bytes[3] === 0x00) {
1664
+ return 'image/x-icon'
1665
+ }
1666
+
1667
+ // TIFF: II*. (49 49 2A 00) or MM.* (4D 4D 00 2A)
1668
+ if (bytes.length >= 4 &&
1669
+ ((bytes[0] === 0x49 && bytes[1] === 0x49 && bytes[2] === 0x2a && bytes[3] === 0x00) ||
1670
+ (bytes[0] === 0x4d && bytes[1] === 0x4d && bytes[2] === 0x00 && bytes[3] === 0x2a))) {
1671
+ return 'image/tiff'
1672
+ }
1673
+
1674
+ // AVIF / HEIC: ftyp box at offset 4 (bytes 4..7 === 'ftyp')
1675
+ if (bytes.length >= 12 &&
1676
+ bytes[4] === 0x66 && bytes[5] === 0x74 && bytes[6] === 0x79 && bytes[7] === 0x70) {
1677
+ const brand = String.fromCharCode(bytes[8], bytes[9], bytes[10], bytes[11]).toLowerCase()
1678
+ if (brand === 'avif' || brand === 'avis') return 'image/avif'
1679
+ if (brand === 'heic' || brand === 'heix' || brand === 'hevc' || brand === 'mif1' || brand === 'msf1') return 'image/heic'
1680
+ }
1681
+
1682
+ return undefined
1683
+ }
1684
+
1685
+ function tryResolveFromAttachmentStore(
1686
+ store: Record<string, unknown>,
1687
+ id: string,
1688
+ context?: unknown,
1689
+ attachmentMeta?: unknown,
1690
+ ): unknown {
1691
+ const idWithoutPrefix = id.startsWith('sha256:') ? id.slice('sha256:'.length) : undefined
1692
+ const idWithPrefix = id.startsWith('sha256:') ? id : `sha256:${id}`
1693
+
1694
+ const candidates: unknown[] = []
1695
+ if (attachmentMeta && typeof attachmentMeta === 'object') {
1696
+ candidates.push(attachmentMeta)
1697
+ }
1698
+ candidates.push({ attachmentId: idWithPrefix })
1699
+ if (idWithoutPrefix !== undefined) {
1700
+ candidates.push({ attachmentId: idWithoutPrefix })
1701
+ }
1702
+ candidates.push({ attachmentId: id })
1703
+ candidates.push({ id: idWithPrefix })
1704
+ candidates.push({ id })
1705
+ if (idWithoutPrefix !== undefined) {
1706
+ candidates.push({ id: idWithoutPrefix })
1707
+ }
1708
+ candidates.push(id)
1709
+ if (idWithoutPrefix !== undefined) {
1710
+ candidates.push(idWithoutPrefix)
1711
+ }
1712
+
1713
+ // Cordis proxies wrap the underlying service instance; if a method call on the proxy fails
1714
+ // or loses this context, fall back to the unwrapped instance (never the class prototype,
1715
+ // which lacks instance state such as `this.root`).
1716
+ const cordisOriginal = Reflect.get(store, Symbol.for('cordis.original')) as Record<string, unknown> | undefined
1717
+
1718
+ // A. LocalAttachmentStore.imageHostPath(ref) -> synchronous local absolute path
1719
+ if (typeof store.imageHostPath === 'function') {
1720
+ const fn = store.imageHostPath as (ref: unknown) => unknown
1721
+ for (const cand of candidates) {
1722
+ if (cand === undefined) continue
1723
+ try {
1724
+ let hostPath: unknown
1725
+ try {
1726
+ hostPath = fn.call(store, cand)
1727
+ } catch {
1728
+ if (cordisOriginal && typeof cordisOriginal.imageHostPath === 'function') {
1729
+ hostPath = (cordisOriginal.imageHostPath as (ref: unknown) => unknown).call(cordisOriginal, cand)
1730
+ }
1731
+ }
1732
+ if (hostPath && typeof (hostPath as Promise<unknown>).then === 'function') {
1733
+ if (typeof (hostPath as Promise<unknown>).catch === 'function') {
1734
+ (hostPath as Promise<unknown>).catch(() => {})
1735
+ }
1736
+ } else if (typeof hostPath === 'string' && hostPath.trim() !== '') {
1737
+ const fileContent = readImageFileFromContext(hostPath, context)
1738
+ return {
1739
+ path: hostPath,
1740
+ ...(fileContent !== undefined ? { data: fileContent } : {}),
1741
+ }
1742
+ }
1743
+ } catch {}
1744
+ }
1745
+ }
1746
+
1747
+ // B. LocalAttachmentStore.fileHostPath(ref) -> fallback for file-type attachments
1748
+ if (typeof store.fileHostPath === 'function') {
1749
+ const fn = store.fileHostPath as (ref: unknown) => unknown
1750
+ for (const cand of candidates) {
1751
+ if (cand === undefined) continue
1752
+ try {
1753
+ let hostPath: unknown
1754
+ try {
1755
+ hostPath = fn.call(store, cand)
1756
+ } catch {
1757
+ if (cordisOriginal && typeof cordisOriginal.fileHostPath === 'function') {
1758
+ hostPath = (cordisOriginal.fileHostPath as (ref: unknown) => unknown).call(cordisOriginal, cand)
1759
+ }
1760
+ }
1761
+ if (hostPath && typeof (hostPath as Promise<unknown>).then === 'function') {
1762
+ if (typeof (hostPath as Promise<unknown>).catch === 'function') {
1763
+ (hostPath as Promise<unknown>).catch(() => {})
1764
+ }
1765
+ } else if (typeof hostPath === 'string' && hostPath.trim() !== '') {
1766
+ const fileContent = readImageFileFromContext(hostPath, context)
1767
+ return {
1768
+ path: hostPath,
1769
+ ...(fileContent !== undefined ? { data: fileContent } : {}),
1770
+ }
1771
+ }
1772
+ } catch {}
1773
+ }
1774
+ }
1775
+
1776
+ // C. LocalAttachmentStore.readImage(ref) -> { ref, data: Uint8Array }
1777
+ if (typeof store.readImage === 'function') {
1778
+ const fn = store.readImage as (ref: unknown) => unknown
1779
+ for (const cand of candidates) {
1780
+ if (cand === undefined) continue
1781
+ try {
1782
+ let res: unknown
1783
+ try {
1784
+ res = fn.call(store, cand)
1785
+ } catch {
1786
+ if (cordisOriginal && typeof cordisOriginal.readImage === 'function') {
1787
+ res = (cordisOriginal.readImage as (ref: unknown) => unknown).call(cordisOriginal, cand)
1788
+ }
1789
+ }
1790
+ if (res && typeof res === 'object') {
1791
+ // If it's a Promise (async), safely attach catch to avoid unhandledRejection crashes
1792
+ if (typeof (res as Promise<unknown>).then === 'function') {
1793
+ if (typeof (res as Promise<unknown>).catch === 'function') {
1794
+ (res as Promise<unknown>).catch(() => {})
1795
+ }
1796
+ } else {
1797
+ if (res instanceof Uint8Array || (typeof Buffer !== 'undefined' && Buffer.isBuffer(res))) {
1798
+ return res
1799
+ }
1800
+ const data = (res as { data?: unknown }).data
1801
+ if (data instanceof Uint8Array || (typeof Buffer !== 'undefined' && Buffer.isBuffer(data)) || typeof data === 'string') {
1802
+ return { data }
1803
+ }
1804
+ }
1805
+ }
1806
+ } catch {}
1807
+ }
1808
+ }
1809
+
1810
+ // D. readAttachment
1811
+ if (typeof store.readAttachment === 'function') {
1812
+ try {
1813
+ let res: unknown
1814
+ try {
1815
+ res = (store.readAttachment as (id: string) => unknown).call(store, id)
1816
+ } catch {
1817
+ if (cordisOriginal && typeof cordisOriginal.readAttachment === 'function') {
1818
+ res = (cordisOriginal.readAttachment as (id: string) => unknown).call(cordisOriginal, id)
1819
+ }
1820
+ }
1821
+ if (res && typeof (res as Promise<unknown>).then === 'function') {
1822
+ if (typeof (res as Promise<unknown>).catch === 'function') {
1823
+ (res as Promise<unknown>).catch(() => {})
1824
+ }
1825
+ } else if (res !== undefined) {
1826
+ return res
1827
+ }
1828
+ } catch {}
1829
+ }
1830
+
1831
+ // E. getAttachment
1832
+ if (typeof store.getAttachment === 'function') {
1833
+ try {
1834
+ let res: unknown
1835
+ try {
1836
+ res = (store.getAttachment as (id: string) => unknown).call(store, id)
1837
+ } catch {
1838
+ if (cordisOriginal && typeof cordisOriginal.getAttachment === 'function') {
1839
+ res = (cordisOriginal.getAttachment as (id: string) => unknown).call(cordisOriginal, id)
1840
+ }
1841
+ }
1842
+ if (res && typeof (res as Promise<unknown>).then === 'function') {
1843
+ if (typeof (res as Promise<unknown>).catch === 'function') {
1844
+ (res as Promise<unknown>).catch(() => {})
1845
+ }
1846
+ } else if (res !== undefined) {
1847
+ return res
1848
+ }
1849
+ } catch {}
1850
+ }
1851
+
1852
+ return undefined
1853
+ }
1854
+
1855
+ function tryStoreFreeDiskAttachment(id: string, context?: unknown): unknown {
1856
+ if (typeof id !== 'string') return undefined
1857
+ let hash: string | undefined
1858
+ const trimmed = id.trim()
1859
+ if (trimmed.startsWith('sha256:')) {
1860
+ hash = trimmed.slice('sha256:'.length).toLowerCase()
1861
+ } else if (/^[0-9a-f]{64}$/i.test(trimmed)) {
1862
+ hash = trimmed.toLowerCase()
1863
+ }
1864
+ if (!hash || hash.length < 2) return undefined
1865
+
1866
+ const prefix = hash.slice(0, 2)
1867
+ const relPaths = [
1868
+ `attachments/v1/objects/${prefix}/${hash}`,
1869
+ `attachments/v1/file-objects/${prefix}/${hash}`,
1870
+ ]
1871
+
1872
+ const candidatePaths: string[] = []
1873
+
1874
+ if (typeof process !== 'undefined' && process.env) {
1875
+ const env = process.env
1876
+ if (env.DSH_NTR_STATE_DIR) {
1877
+ const stateDir = env.DSH_NTR_STATE_DIR.replaceAll('\\', '/').replace(/\/+$/, '')
1878
+ const hostDir = stateDir.endsWith('/dsh-ntr') ? stateDir.slice(0, -'/dsh-ntr'.length) : stateDir
1879
+ for (const rel of relPaths) candidatePaths.push(`${hostDir}/${rel}`)
1880
+ }
1881
+ if (env.DSH_DIR) {
1882
+ const dshDir = env.DSH_DIR.replaceAll('\\', '/').replace(/\/+$/, '')
1883
+ for (const rel of relPaths) candidatePaths.push(`${dshDir}/${rel}`)
1884
+ }
1885
+ const home = env.HOME || env.USERPROFILE || (env.HOMEDRIVE && env.HOMEPATH ? `${env.HOMEDRIVE}${env.HOMEPATH}` : undefined)
1886
+ if (home) {
1887
+ const cleanHome = home.replaceAll('\\', '/').replace(/\/+$/, '')
1888
+ for (const rel of relPaths) {
1889
+ candidatePaths.push(`${cleanHome}/.dsh/${rel}`)
1890
+ candidatePaths.push(`${cleanHome}\\.dsh\\${rel.replaceAll('/', '\\')}`)
1891
+ }
1892
+ }
1893
+ }
1894
+ for (const rel of relPaths) candidatePaths.push(`~/.dsh/${rel}`)
1895
+
1896
+ for (const p of candidatePaths) {
1897
+ const fileContent = readImageFileFromContext(p, context)
1898
+ if (fileContent !== undefined) {
1899
+ return {
1900
+ path: p,
1901
+ data: fileContent,
1902
+ }
1903
+ }
1904
+ }
1905
+
1906
+ return undefined
1907
+ }
1908
+
1909
+ export function resolveImageAttachmentFromContext(
1910
+ id: string,
1911
+ context?: unknown,
1912
+ attachmentMeta?: unknown,
1913
+ rootContext?: unknown,
1914
+ ): unknown {
1915
+ const root = rootContext ?? context
1916
+ if (context && typeof context === 'object') {
1917
+ const ctx = context as Record<string, unknown>
1918
+
1919
+ const direct = tryResolveFromAttachmentStore(ctx, id, root, attachmentMeta)
1920
+ if (direct !== undefined) return direct
1921
+
1922
+ if (typeof ctx.get === 'function') {
1923
+ try {
1924
+ const svc = (ctx.get as (name: string) => unknown)('attachments') ?? (ctx.get as (name: string) => unknown)('attachmentService')
1925
+ if (svc && typeof svc === 'object') {
1926
+ const res = resolveImageAttachmentFromContext(id, svc, attachmentMeta, root)
1927
+ if (res !== undefined) return res
1928
+ }
1929
+ } catch {}
1930
+ }
1931
+
1932
+ try {
1933
+ if (ctx.attachmentService && typeof ctx.attachmentService === 'object') {
1934
+ const res = resolveImageAttachmentFromContext(id, ctx.attachmentService, attachmentMeta, root)
1935
+ if (res !== undefined) return res
1936
+ }
1937
+ } catch {}
1938
+
1939
+ try {
1940
+ const attStore = ctx.attachments
1941
+ if (attStore) {
1942
+ if (typeof attStore === 'object') {
1943
+ const fromStore = tryResolveFromAttachmentStore(attStore as Record<string, unknown>, id, root, attachmentMeta)
1944
+ if (fromStore !== undefined) return fromStore
1945
+ }
1946
+ if (attStore instanceof Map || typeof (attStore as { get?: unknown }).get === 'function') {
1947
+ try { return (attStore as Map<string, unknown>).get(id) } catch {}
1948
+ }
1949
+ if (typeof attStore === 'object' && Object.prototype.hasOwnProperty.call(attStore, id)) {
1950
+ return (attStore as Record<string, unknown>)[id]
1951
+ }
1952
+ }
1953
+ } catch {}
1954
+ }
1955
+
1956
+ return tryStoreFreeDiskAttachment(id, root)
1957
+ }
1958
+
1959
+ function toBase64(data: Buffer | Uint8Array | string): string {
1960
+ if (typeof data === 'string') return data.replace(/\s+/g, '')
1961
+ if (typeof Buffer !== 'undefined') return Buffer.from(data).toString('base64')
1962
+ let binary = ''
1963
+ const bytes = data instanceof Uint8Array ? data : new Uint8Array(data)
1964
+ for (let i = 0; i < bytes.byteLength; i++) {
1965
+ binary += String.fromCharCode(bytes[i])
1966
+ }
1967
+ return btoa(binary)
1968
+ }
1969
+
1970
+ /**
1971
+ * Extract an image payload (Base64 data + MIME type) from an image block and optional context.
1972
+ */
1973
+ export function resolveImageContent(block: unknown, context?: unknown): PromptImageContent | undefined {
1974
+ if (!block || typeof block !== 'object') return undefined
1975
+ const b = block as Record<string, unknown>
1976
+ const attRecord = b.attachment && typeof b.attachment === 'object' ? (b.attachment as Record<string, unknown>) : undefined
1977
+ const isExplicitImageBlock = b.type === 'image' || b.type === 'image_url' || b.type === 'attachment'
1978
+ const explicitMime = (typeof attRecord?.mediaType === 'string' && attRecord.mediaType.trim().toLowerCase()) ||
1979
+ (typeof attRecord?.mimeType === 'string' && attRecord.mimeType.trim().toLowerCase()) ||
1980
+ (typeof b.mediaType === 'string' && b.mediaType.trim().toLowerCase()) ||
1981
+ (typeof b.mimeType === 'string' && b.mimeType.trim().toLowerCase()) ||
1982
+ (typeof b.contentType === 'string' && b.contentType.trim().toLowerCase()) ||
1983
+ undefined
1984
+
1985
+ // 1. Direct base64 data: { data: string | Buffer | Uint8Array, mimeType?: string }
1986
+ if (typeof b.data === 'string' && b.data.trim() !== '') {
1987
+ if (b.data.startsWith('data:')) {
1988
+ const parsed = parseImageDataUrl(b.data)
1989
+ if (parsed) {
1990
+ const mimeType = explicitMime || parsed.mimeType || detectImageMimeFromMagic(parsed.data) || 'image/png'
1991
+ return { type: 'image', data: parsed.data, mimeType, ...(typeof b.uri === 'string' ? { uri: b.uri } : {}) }
1992
+ }
1993
+ return undefined
1994
+ }
1995
+ const mimeType = explicitMime || detectImageMimeFromMagic(b.data) || (isExplicitImageBlock ? 'image/png' : undefined)
1996
+ if (mimeType && mimeType.startsWith('image/')) {
1997
+ return {
1998
+ type: 'image',
1999
+ data: b.data.trim().replace(/\s+/g, ''),
2000
+ mimeType,
2001
+ ...(typeof b.uri === 'string' ? { uri: b.uri } : {}),
2002
+ }
2003
+ }
2004
+ }
2005
+ if (typeof Buffer !== 'undefined' && Buffer.isBuffer(b.data)) {
2006
+ const mimeType = explicitMime || detectImageMimeFromMagic(b.data) || (isExplicitImageBlock ? 'image/png' : undefined)
2007
+ if (mimeType && mimeType.startsWith('image/')) {
2008
+ return { type: 'image', data: b.data.toString('base64'), mimeType, ...(typeof b.uri === 'string' ? { uri: b.uri } : {}) }
2009
+ }
2010
+ }
2011
+ if (b.data instanceof Uint8Array) {
2012
+ const mimeType = explicitMime || detectImageMimeFromMagic(b.data) || (isExplicitImageBlock ? 'image/png' : undefined)
2013
+ if (mimeType && mimeType.startsWith('image/')) {
2014
+ return { type: 'image', data: toBase64(b.data), mimeType, ...(typeof b.uri === 'string' ? { uri: b.uri } : {}) }
2015
+ }
2016
+ }
2017
+
2018
+ // 2. Anthropic style: source: { type: 'base64', media_type: string, data: string }
2019
+ if (b.source && typeof b.source === 'object') {
2020
+ const src = b.source as Record<string, unknown>
2021
+ const srcMime = (typeof src.media_type === 'string' && src.media_type.trim().toLowerCase()) ||
2022
+ (typeof src.mimeType === 'string' && src.mimeType.trim().toLowerCase()) ||
2023
+ explicitMime ||
2024
+ (typeof src.data === 'string' || src.data instanceof Uint8Array || (typeof Buffer !== 'undefined' && Buffer.isBuffer(src.data)) ? detectImageMimeFromMagic(src.data as Buffer | Uint8Array | string) : undefined) ||
2025
+ 'image/png'
2026
+ if (srcMime.startsWith('image/')) {
2027
+ if (typeof src.data === 'string' && src.data.trim() !== '') {
2028
+ return {
2029
+ type: 'image',
2030
+ data: src.data.trim().replace(/\s+/g, ''),
2031
+ mimeType: srcMime,
2032
+ ...(typeof b.uri === 'string' ? { uri: b.uri } : {}),
2033
+ }
2034
+ }
2035
+ if (typeof Buffer !== 'undefined' && Buffer.isBuffer(src.data)) {
2036
+ return { type: 'image', data: src.data.toString('base64'), mimeType: srcMime, ...(typeof b.uri === 'string' ? { uri: b.uri } : {}) }
2037
+ }
2038
+ if (src.data instanceof Uint8Array) {
2039
+ return { type: 'image', data: toBase64(src.data), mimeType: srcMime, ...(typeof b.uri === 'string' ? { uri: b.uri } : {}) }
2040
+ }
2041
+ }
2042
+ }
2043
+
2044
+ // 3. Image property: { image: string | Buffer | Uint8Array }
2045
+ if (typeof b.image === 'string' && b.image.trim() !== '') {
2046
+ if (b.image.startsWith('data:')) {
2047
+ const parsed = parseImageDataUrl(b.image)
2048
+ if (parsed) return { type: 'image', data: parsed.data, mimeType: explicitMime || parsed.mimeType || detectImageMimeFromMagic(parsed.data) || 'image/png' }
2049
+ return undefined
2050
+ }
2051
+ const mimeType = explicitMime || detectImageMimeFromMagic(b.image) || (isExplicitImageBlock ? 'image/png' : undefined)
2052
+ if (mimeType && mimeType.startsWith('image/')) {
2053
+ return { type: 'image', data: b.image.trim().replace(/\s+/g, ''), mimeType }
2054
+ }
2055
+ }
2056
+ if (typeof Buffer !== 'undefined' && Buffer.isBuffer(b.image)) {
2057
+ const mimeType = explicitMime || detectImageMimeFromMagic(b.image) || (isExplicitImageBlock ? 'image/png' : undefined)
2058
+ if (mimeType && mimeType.startsWith('image/')) return { type: 'image', data: b.image.toString('base64'), mimeType }
2059
+ }
2060
+ if (b.image instanceof Uint8Array) {
2061
+ const mimeType = explicitMime || detectImageMimeFromMagic(b.image) || (isExplicitImageBlock ? 'image/png' : undefined)
2062
+ if (mimeType && mimeType.startsWith('image/')) return { type: 'image', data: toBase64(b.image), mimeType }
2063
+ }
2064
+
2065
+ // 4. OpenAI style image_url: { image_url: string | { url: string } }
2066
+ if (b.image_url) {
2067
+ const urlStr = typeof b.image_url === 'string'
2068
+ ? b.image_url
2069
+ : (typeof b.image_url === 'object' && b.image_url !== null ? (b.image_url as Record<string, unknown>).url : undefined)
2070
+ if (typeof urlStr === 'string' && urlStr.trim() !== '') {
2071
+ if (urlStr.startsWith('data:')) {
2072
+ const parsed = parseImageDataUrl(urlStr)
2073
+ if (parsed) return { type: 'image', data: parsed.data, mimeType: explicitMime || parsed.mimeType || detectImageMimeFromMagic(parsed.data) || 'image/png', uri: urlStr }
2074
+ return undefined
2075
+ }
2076
+ if (urlStr.startsWith('http://') || urlStr.startsWith('https://')) {
2077
+ const mimeType = explicitMime || guessImageMimeType(urlStr) || 'image/png'
2078
+ return { type: 'image', data: '', mimeType, uri: urlStr }
2079
+ }
2080
+ if (urlStr.startsWith('file://') || isLocalFilePath(urlStr)) {
2081
+ const fileContent = readImageFileFromContext(urlStr, context)
2082
+ if (fileContent !== undefined) {
2083
+ let data: string
2084
+ let resolvedMime: string | undefined
2085
+ if (typeof fileContent === 'string') {
2086
+ if (fileContent.startsWith('data:')) {
2087
+ const parsed = parseImageDataUrl(fileContent)
2088
+ if (parsed) {
2089
+ data = parsed.data
2090
+ resolvedMime = parsed.mimeType
2091
+ } else {
2092
+ return undefined
2093
+ }
2094
+ } else {
2095
+ data = fileContent.trim().replace(/\s+/g, '')
2096
+ }
2097
+ } else {
2098
+ data = toBase64(fileContent)
2099
+ }
2100
+ const mimeType = explicitMime || resolvedMime || guessImageMimeType(urlStr) || detectImageMimeFromMagic(fileContent) || (isExplicitImageBlock ? 'image/png' : undefined)
2101
+ if (mimeType && mimeType.startsWith('image/')) {
2102
+ return { type: 'image', data, mimeType, uri: urlStr }
2103
+ }
2104
+ }
2105
+ }
2106
+ }
2107
+ }
2108
+
2109
+ // 5. Direct buffer / content on block: { buffer: Buffer } or { content: Buffer }
2110
+ if (b.buffer && (typeof Buffer !== 'undefined' && Buffer.isBuffer(b.buffer) || b.buffer instanceof Uint8Array)) {
2111
+ const data = typeof Buffer !== 'undefined' && Buffer.isBuffer(b.buffer) ? b.buffer.toString('base64') : toBase64(b.buffer as Uint8Array)
2112
+ const mimeType = explicitMime || detectImageMimeFromMagic(b.buffer as Buffer | Uint8Array) || (isExplicitImageBlock ? 'image/png' : undefined)
2113
+ if (mimeType && mimeType.startsWith('image/')) {
2114
+ return { type: 'image', data, mimeType, ...(typeof b.uri === 'string' ? { uri: b.uri } : {}) }
2115
+ }
2116
+ }
2117
+ if (b.content && (typeof Buffer !== 'undefined' && Buffer.isBuffer(b.content) || b.content instanceof Uint8Array)) {
2118
+ const data = typeof Buffer !== 'undefined' && Buffer.isBuffer(b.content) ? b.content.toString('base64') : toBase64(b.content as Uint8Array)
2119
+ const mimeType = explicitMime || detectImageMimeFromMagic(b.content as Buffer | Uint8Array) || (isExplicitImageBlock ? 'image/png' : undefined)
2120
+ if (mimeType && mimeType.startsWith('image/')) {
2121
+ return { type: 'image', data, mimeType, ...(typeof b.uri === 'string' ? { uri: b.uri } : {}) }
2122
+ }
2123
+ }
2124
+
2125
+ // 6. Data URL or Remote URL in `url`
2126
+ if (typeof b.url === 'string') {
2127
+ if (b.url.startsWith('data:')) {
2128
+ const parsed = parseImageDataUrl(b.url)
2129
+ if (parsed) return { type: 'image', data: parsed.data, mimeType: explicitMime || parsed.mimeType || detectImageMimeFromMagic(parsed.data) || 'image/png', uri: b.url }
2130
+ return undefined
2131
+ }
2132
+ if (b.url.startsWith('http://') || b.url.startsWith('https://')) {
2133
+ const mimeType = explicitMime || guessImageMimeType(b.url) || 'image/png'
2134
+ return { type: 'image', data: '', mimeType, uri: b.url }
2135
+ }
2136
+ }
2137
+
2138
+ // 7. File path in `path`, `filePath`, or `url: file://...`
2139
+ let filePath: string | undefined
2140
+ if (typeof b.path === 'string' && b.path !== '') filePath = b.path
2141
+ else if (typeof b.filePath === 'string' && b.filePath !== '') filePath = b.filePath
2142
+ else if (typeof b.url === 'string' && (b.url.startsWith('file://') || isLocalFilePath(b.url))) filePath = b.url
2143
+
2144
+ if (filePath !== undefined) {
2145
+ const fileContent = readImageFileFromContext(filePath, context)
2146
+ if (fileContent !== undefined) {
2147
+ let data: string
2148
+ let resolvedMime: string | undefined
2149
+ if (typeof fileContent === 'string') {
2150
+ if (fileContent.startsWith('data:')) {
2151
+ const parsed = parseImageDataUrl(fileContent)
2152
+ if (parsed) {
2153
+ data = parsed.data
2154
+ resolvedMime = parsed.mimeType
2155
+ } else {
2156
+ return undefined
2157
+ }
2158
+ } else {
2159
+ data = fileContent.trim().replace(/\s+/g, '')
2160
+ }
2161
+ } else {
2162
+ data = toBase64(fileContent)
2163
+ }
2164
+ const mimeType = explicitMime ||
2165
+ resolvedMime ||
2166
+ guessImageMimeType(filePath) ||
2167
+ detectImageMimeFromMagic(fileContent) ||
2168
+ (isExplicitImageBlock ? 'image/png' : undefined)
2169
+
2170
+ if (mimeType && mimeType.startsWith('image/')) {
2171
+ let uri = typeof b.url === 'string' ? b.url : undefined
2172
+ if (!uri) {
2173
+ if (filePath.startsWith('file://')) {
2174
+ uri = filePath
2175
+ } else if (isAbsolutePath(filePath)) {
2176
+ if (filePath.startsWith('//') || filePath.startsWith('\\\\')) {
2177
+ uri = `file://${filePath.replace(/^[\/\\]+/, '').replace(/\\/g, '/')}`
2178
+ } else if (/^[a-zA-Z]:/.test(filePath)) {
2179
+ uri = `file:///${filePath.replace(/\\/g, '/')}`
2180
+ } else {
2181
+ uri = `file://${filePath.startsWith('/') ? filePath : `/${filePath}`}`
2182
+ }
2183
+ } else {
2184
+ uri = filePath
2185
+ }
2186
+ }
2187
+ return {
2188
+ type: 'image',
2189
+ data,
2190
+ mimeType,
2191
+ uri,
2192
+ }
2193
+ }
2194
+ }
2195
+ }
2196
+
2197
+ // 8. Attachment resolution via context
2198
+ let attachmentId: string | undefined
2199
+ if (attRecord && typeof attRecord.attachmentId === 'string' && attRecord.attachmentId.trim() !== '') {
2200
+ attachmentId = attRecord.attachmentId.trim()
2201
+ } else if (typeof b.attachmentId === 'string' && b.attachmentId.trim() !== '') {
2202
+ attachmentId = b.attachmentId.trim()
2203
+ } else if (attRecord && typeof attRecord.id === 'string' && attRecord.id.trim() !== '') {
2204
+ attachmentId = attRecord.id.trim()
2205
+ } else if (typeof b.id === 'string' && b.id.trim() !== '') {
2206
+ attachmentId = b.id.trim()
2207
+ } else if (typeof b.attachment === 'string' && b.attachment.trim() !== '') {
2208
+ attachmentId = b.attachment.trim()
2209
+ } else if (typeof b.url === 'string' && b.url.startsWith('attachment:')) {
2210
+ attachmentId = b.url.replace(/^attachment:\/\/|^attachment:/, '')
2211
+ } else if (typeof b.image_url === 'string' && b.image_url.startsWith('attachment:')) {
2212
+ attachmentId = b.image_url.replace(/^attachment:\/\/|^attachment:/, '')
2213
+ } else if (b.image_url && typeof b.image_url === 'object' && typeof (b.image_url as Record<string, unknown>).url === 'string' && ((b.image_url as Record<string, unknown>).url as string).startsWith('attachment:')) {
2214
+ attachmentId = ((b.image_url as Record<string, unknown>).url as string).replace(/^attachment:\/\/|^attachment:/, '')
2215
+ }
2216
+
2217
+ if (attachmentId !== undefined) {
2218
+ const rawAtt = resolveImageAttachmentFromContext(attachmentId, context, b.attachment)
2219
+ if (rawAtt !== undefined) {
2220
+ if (typeof rawAtt === 'string') {
2221
+ if (rawAtt.startsWith('data:')) {
2222
+ const parsed = parseImageDataUrl(rawAtt)
2223
+ if (parsed) {
2224
+ const mimeType = explicitMime || parsed.mimeType || detectImageMimeFromMagic(parsed.data) || 'image/png'
2225
+ return {
2226
+ type: 'image',
2227
+ data: parsed.data,
2228
+ mimeType,
2229
+ uri: typeof b.uri === 'string' ? b.uri : `attachment://${attachmentId}`,
2230
+ }
2231
+ }
2232
+ return undefined
2233
+ } else if (rawAtt.startsWith('file://') || isLocalFilePath(rawAtt)) {
2234
+ const fileContent = readImageFileFromContext(rawAtt, context)
2235
+ if (fileContent !== undefined) {
2236
+ let data: string
2237
+ let resolvedMime: string | undefined
2238
+ if (typeof fileContent === 'string') {
2239
+ if (fileContent.startsWith('data:')) {
2240
+ const parsed = parseImageDataUrl(fileContent)
2241
+ if (parsed) {
2242
+ data = parsed.data
2243
+ resolvedMime = parsed.mimeType
2244
+ } else {
2245
+ return undefined
2246
+ }
2247
+ } else {
2248
+ data = fileContent.trim().replace(/\s+/g, '')
2249
+ }
2250
+ } else {
2251
+ data = toBase64(fileContent)
2252
+ }
2253
+ const mimeType = explicitMime ||
2254
+ resolvedMime ||
2255
+ (typeof attRecord?.name === 'string' ? guessImageMimeType(attRecord.name) : undefined) ||
2256
+ guessImageMimeType(rawAtt) ||
2257
+ detectImageMimeFromMagic(fileContent) ||
2258
+ (isExplicitImageBlock ? 'image/png' : undefined)
2259
+ if (mimeType && mimeType.startsWith('image/')) {
2260
+ return { type: 'image', data, mimeType, uri: typeof b.uri === 'string' ? b.uri : `attachment://${attachmentId}` }
2261
+ }
2262
+ }
2263
+ }
2264
+ const mimeType = explicitMime ||
2265
+ (typeof attRecord?.name === 'string' ? guessImageMimeType(attRecord.name) : undefined) ||
2266
+ guessImageMimeType(attachmentId) ||
2267
+ detectImageMimeFromMagic(rawAtt) ||
2268
+ (isExplicitImageBlock ? 'image/png' : undefined)
2269
+ if (mimeType && mimeType.startsWith('image/')) {
2270
+ return { type: 'image', data: rawAtt.trim().replace(/\s+/g, ''), mimeType, uri: typeof b.uri === 'string' ? b.uri : `attachment://${attachmentId}` }
2271
+ }
2272
+ }
2273
+ if (typeof rawAtt === 'object' && rawAtt !== null) {
2274
+ if (typeof Buffer !== 'undefined' && Buffer.isBuffer(rawAtt)) {
2275
+ const mimeType = explicitMime ||
2276
+ (typeof attRecord?.name === 'string' ? guessImageMimeType(attRecord.name) : undefined) ||
2277
+ guessImageMimeType(attachmentId) ||
2278
+ detectImageMimeFromMagic(rawAtt) ||
2279
+ (isExplicitImageBlock ? 'image/png' : undefined)
2280
+ if (mimeType && mimeType.startsWith('image/')) {
2281
+ return {
2282
+ type: 'image',
2283
+ data: rawAtt.toString('base64'),
2284
+ mimeType,
2285
+ uri: typeof b.uri === 'string' ? b.uri : `attachment://${attachmentId}`,
2286
+ }
2287
+ }
2288
+ }
2289
+ if (rawAtt instanceof Uint8Array) {
2290
+ const mimeType = explicitMime ||
2291
+ (typeof attRecord?.name === 'string' ? guessImageMimeType(attRecord.name) : undefined) ||
2292
+ guessImageMimeType(attachmentId) ||
2293
+ detectImageMimeFromMagic(rawAtt) ||
2294
+ (isExplicitImageBlock ? 'image/png' : undefined)
2295
+ if (mimeType && mimeType.startsWith('image/')) {
2296
+ return {
2297
+ type: 'image',
2298
+ data: toBase64(rawAtt),
2299
+ mimeType,
2300
+ uri: typeof b.uri === 'string' ? b.uri : `attachment://${attachmentId}`,
2301
+ }
2302
+ }
2303
+ }
2304
+ const attObj = rawAtt as Record<string, unknown>
2305
+ let data: string | undefined
2306
+ let resolvedMime: string | undefined
2307
+
2308
+ const attPath = typeof attObj.path === 'string' && attObj.path !== ''
2309
+ ? attObj.path
2310
+ : (typeof attObj.filePath === 'string' && attObj.filePath !== ''
2311
+ ? attObj.filePath
2312
+ : (typeof attObj.url === 'string' && (attObj.url.startsWith('file://') || isLocalFilePath(attObj.url)) ? attObj.url : undefined))
2313
+
2314
+ let fileContentForMagic: Buffer | Uint8Array | string | undefined
2315
+
2316
+ if (attPath !== undefined) {
2317
+ const fileContent = readImageFileFromContext(attPath, context)
2318
+ if (fileContent !== undefined) {
2319
+ fileContentForMagic = fileContent
2320
+ if (typeof fileContent === 'string') {
2321
+ if (fileContent.startsWith('data:')) {
2322
+ const parsed = parseImageDataUrl(fileContent)
2323
+ if (parsed) {
2324
+ data = parsed.data
2325
+ resolvedMime = parsed.mimeType
2326
+ }
2327
+ } else {
2328
+ data = fileContent.trim().replace(/\s+/g, '')
2329
+ }
2330
+ } else {
2331
+ data = toBase64(fileContent)
2332
+ }
2333
+ if (!resolvedMime) resolvedMime = guessImageMimeType(attPath)
2334
+ }
2335
+ }
2336
+
2337
+ if (data === undefined) {
2338
+ if (typeof attObj.data === 'string' && attObj.data.trim() !== '') {
2339
+ if (attObj.data.startsWith('data:')) {
2340
+ const parsed = parseImageDataUrl(attObj.data)
2341
+ if (parsed) {
2342
+ data = parsed.data
2343
+ resolvedMime = parsed.mimeType
2344
+ }
2345
+ } else {
2346
+ data = attObj.data.trim().replace(/\s+/g, '')
2347
+ fileContentForMagic = attObj.data
2348
+ }
2349
+ }
2350
+ else if (typeof Buffer !== 'undefined' && Buffer.isBuffer(attObj.data)) {
2351
+ data = attObj.data.toString('base64')
2352
+ fileContentForMagic = attObj.data
2353
+ }
2354
+ else if (attObj.data instanceof Uint8Array) {
2355
+ data = toBase64(attObj.data)
2356
+ fileContentForMagic = attObj.data
2357
+ }
2358
+ else if (typeof Buffer !== 'undefined' && Buffer.isBuffer(attObj.buffer)) {
2359
+ data = attObj.buffer.toString('base64')
2360
+ fileContentForMagic = attObj.buffer
2361
+ }
2362
+ else if (attObj.buffer instanceof Uint8Array) {
2363
+ data = toBase64(attObj.buffer)
2364
+ fileContentForMagic = attObj.buffer
2365
+ }
2366
+ else if (typeof Buffer !== 'undefined' && Buffer.isBuffer(attObj.content)) {
2367
+ data = attObj.content.toString('base64')
2368
+ fileContentForMagic = attObj.content
2369
+ }
2370
+ else if (attObj.content instanceof Uint8Array) {
2371
+ data = toBase64(attObj.content)
2372
+ fileContentForMagic = attObj.content
2373
+ }
2374
+ else if (typeof attObj.content === 'string') {
2375
+ if (attObj.content.startsWith('data:')) {
2376
+ const parsed = parseImageDataUrl(attObj.content)
2377
+ if (parsed) {
2378
+ data = parsed.data
2379
+ resolvedMime = parsed.mimeType
2380
+ }
2381
+ } else {
2382
+ data = attObj.content.trim().replace(/\s+/g, '')
2383
+ fileContentForMagic = attObj.content
2384
+ }
2385
+ }
2386
+ }
2387
+
2388
+ if (data !== undefined) {
2389
+ const mimeType = explicitMime ||
2390
+ (typeof attObj.mediaType === 'string' && attObj.mediaType.toLowerCase()) ||
2391
+ (typeof attObj.mimeType === 'string' && attObj.mimeType.toLowerCase()) ||
2392
+ resolvedMime ||
2393
+ (typeof attRecord?.name === 'string' ? guessImageMimeType(attRecord.name) : undefined) ||
2394
+ (typeof attObj.name === 'string' ? guessImageMimeType(attObj.name as string) : undefined) ||
2395
+ guessImageMimeType(attachmentId) ||
2396
+ detectImageMimeFromMagic(fileContentForMagic ?? data) ||
2397
+ (isExplicitImageBlock ? 'image/png' : undefined)
2398
+ if (mimeType && mimeType.startsWith('image/')) {
2399
+ return {
2400
+ type: 'image',
2401
+ data,
2402
+ mimeType,
2403
+ uri: typeof b.uri === 'string' ? b.uri : `attachment://${attachmentId}`,
2404
+ }
2405
+ }
2406
+ }
2407
+ }
2408
+ }
2409
+ }
2410
+
2411
+ // 9. Direct b.attachment object
2412
+ if (b.attachment && typeof b.attachment === 'object') {
2413
+ const attObj = b.attachment as Record<string, unknown>
2414
+ let data: string | undefined
2415
+ let resolvedMime: string | undefined
2416
+
2417
+ const attPath = typeof attObj.path === 'string' && attObj.path !== ''
2418
+ ? attObj.path
2419
+ : (typeof attObj.filePath === 'string' && attObj.filePath !== ''
2420
+ ? attObj.filePath
2421
+ : (typeof attObj.url === 'string' && (attObj.url.startsWith('file://') || isLocalFilePath(attObj.url)) ? attObj.url : undefined))
2422
+
2423
+ let fileContentForMagic: Buffer | Uint8Array | string | undefined
2424
+
2425
+ if (attPath !== undefined) {
2426
+ const fileContent = readImageFileFromContext(attPath, context)
2427
+ if (fileContent !== undefined) {
2428
+ fileContentForMagic = fileContent
2429
+ if (typeof fileContent === 'string') {
2430
+ if (fileContent.startsWith('data:')) {
2431
+ const parsed = parseImageDataUrl(fileContent)
2432
+ if (parsed) {
2433
+ data = parsed.data
2434
+ resolvedMime = parsed.mimeType
2435
+ }
2436
+ } else {
2437
+ data = fileContent.trim().replace(/\s+/g, '')
2438
+ }
2439
+ } else {
2440
+ data = toBase64(fileContent)
2441
+ }
2442
+ if (!resolvedMime) resolvedMime = guessImageMimeType(attPath)
2443
+ }
2444
+ }
2445
+
2446
+ if (data === undefined) {
2447
+ if (typeof attObj.data === 'string' && attObj.data.trim() !== '') {
2448
+ if (attObj.data.startsWith('data:')) {
2449
+ const parsed = parseImageDataUrl(attObj.data)
2450
+ if (parsed) {
2451
+ data = parsed.data
2452
+ resolvedMime = parsed.mimeType
2453
+ }
2454
+ } else {
2455
+ data = attObj.data.trim().replace(/\s+/g, '')
2456
+ fileContentForMagic = attObj.data
2457
+ }
2458
+ }
2459
+ else if (typeof Buffer !== 'undefined' && Buffer.isBuffer(attObj.data)) {
2460
+ data = attObj.data.toString('base64')
2461
+ fileContentForMagic = attObj.data
2462
+ }
2463
+ else if (attObj.data instanceof Uint8Array) {
2464
+ data = toBase64(attObj.data)
2465
+ fileContentForMagic = attObj.data
2466
+ }
2467
+ else if (typeof Buffer !== 'undefined' && Buffer.isBuffer(attObj.buffer)) {
2468
+ data = attObj.buffer.toString('base64')
2469
+ fileContentForMagic = attObj.buffer
2470
+ }
2471
+ else if (attObj.buffer instanceof Uint8Array) {
2472
+ data = toBase64(attObj.buffer)
2473
+ fileContentForMagic = attObj.buffer
2474
+ }
2475
+ else if (typeof Buffer !== 'undefined' && Buffer.isBuffer(attObj.content)) {
2476
+ data = attObj.content.toString('base64')
2477
+ fileContentForMagic = attObj.content
2478
+ }
2479
+ else if (attObj.content instanceof Uint8Array) {
2480
+ data = toBase64(attObj.content)
2481
+ fileContentForMagic = attObj.content
2482
+ }
2483
+ else if (typeof attObj.content === 'string') {
2484
+ if (attObj.content.startsWith('data:')) {
2485
+ const parsed = parseImageDataUrl(attObj.content)
2486
+ if (parsed) {
2487
+ data = parsed.data
2488
+ resolvedMime = parsed.mimeType
2489
+ }
2490
+ } else {
2491
+ data = attObj.content.trim().replace(/\s+/g, '')
2492
+ fileContentForMagic = attObj.content
2493
+ }
2494
+ }
2495
+ }
2496
+
2497
+ if (data !== undefined) {
2498
+ const mimeType = explicitMime ||
2499
+ (typeof attObj.mediaType === 'string' && attObj.mediaType.toLowerCase()) ||
2500
+ (typeof attObj.mimeType === 'string' && attObj.mimeType.toLowerCase()) ||
2501
+ resolvedMime ||
2502
+ (typeof attObj.name === 'string' ? guessImageMimeType(attObj.name as string) : undefined) ||
2503
+ (attPath ? guessImageMimeType(attPath) : undefined) ||
2504
+ detectImageMimeFromMagic(fileContentForMagic ?? data) ||
2505
+ (isExplicitImageBlock ? 'image/png' : undefined)
2506
+ if (mimeType && mimeType.startsWith('image/')) {
2507
+ return { type: 'image', data, mimeType, ...(typeof b.uri === 'string' ? { uri: b.uri } : {}) }
2508
+ }
2509
+ }
2510
+ }
2511
+
2512
+ return undefined
2513
+ }
1390
2514
  export type ForwardPromptSelection =
1391
- | { kind: 'prompt'; text: string; id?: string }
2515
+ | {
2516
+ kind: 'prompt'
2517
+ text: string
2518
+ id?: string
2519
+ images?: PromptImageContent[]
2520
+ content?: PromptContentBlock[]
2521
+ }
1392
2522
  | { kind: 'no-user-input' }
1393
2523
  | { kind: 'empty' }
1394
2524
 
@@ -1405,28 +2535,72 @@ export type ForwardPromptSelection =
1405
2535
  * (tests, headless callers) and counts as user input.
1406
2536
  *
1407
2537
  * @param messages - the request's ordered messages, exactly as the host built them.
1408
- * @returns the last real user message's text, or why there is none to forward.
2538
+ * @param context - optional host context or attachment resolver for reading attachments.
2539
+ * @returns the last real user message's text and any image content blocks, or why there is none to forward.
1409
2540
  */
1410
2541
  export function selectForwardPrompt(messages: readonly {
1411
2542
  role: string
1412
- content?: Array<{ type?: string; text?: string }>
2543
+ content?: string | Array<{ type?: string; text?: string; [k: string]: unknown }> | { type?: string; text?: string; [k: string]: unknown }
1413
2544
  source?: { kind?: string }
1414
2545
  id?: string
1415
- }[]): ForwardPromptSelection {
2546
+ }[], context?: unknown): ForwardPromptSelection {
1416
2547
  let sawUserRole = false
1417
- let last: { text: string; id?: string } | undefined
2548
+ let last: {
2549
+ text: string
2550
+ id?: string
2551
+ images?: PromptImageContent[]
2552
+ content?: PromptContentBlock[]
2553
+ } | undefined
1418
2554
  for (const message of messages) {
1419
2555
  if (message.role !== 'user') continue
1420
2556
  sawUserRole = true
1421
2557
  const kind = message.source?.kind
1422
2558
  if (kind !== undefined && kind !== 'user') continue
1423
- const text = (message.content ?? [])
2559
+
2560
+ const content = message.content ?? (message as { parts?: unknown }).parts
2561
+ const rawBlocks: Array<Record<string, unknown>> = Array.isArray(content)
2562
+ ? (content as Array<Record<string, unknown>>)
2563
+ : (typeof content === 'string'
2564
+ ? [{ type: 'text', text: content }]
2565
+ : (typeof content === 'object' && content !== null ? [content as Record<string, unknown>] : []))
2566
+
2567
+ const text = rawBlocks
1424
2568
  .filter(block => block.type === 'text' && typeof block.text === 'string')
1425
2569
  .map(block => block.text as string)
1426
2570
  .join('\n')
1427
2571
  .trim()
1428
- if (text === '') continue
1429
- last = { text, ...(message.id === undefined ? {} : { id: message.id }) }
2572
+
2573
+ const images: PromptImageContent[] = []
2574
+ const contentBlocks: PromptContentBlock[] = []
2575
+
2576
+ for (const block of rawBlocks) {
2577
+ if (!block || typeof block !== 'object') continue
2578
+ if (block.type === 'text' && typeof block.text === 'string') {
2579
+ if (block.text.trim() !== '') {
2580
+ contentBlocks.push({ type: 'text', text: block.text })
2581
+ }
2582
+ } else {
2583
+ const resolved = resolveImageContent(block, context)
2584
+ if (resolved !== undefined) {
2585
+ images.push(resolved)
2586
+ contentBlocks.push(resolved)
2587
+ }
2588
+ }
2589
+ }
2590
+
2591
+ if (text === '' && images.length === 0) continue
2592
+
2593
+ const item: {
2594
+ text: string
2595
+ id?: string
2596
+ images?: PromptImageContent[]
2597
+ content?: PromptContentBlock[]
2598
+ } = { text, ...(message.id === undefined ? {} : { id: message.id }) }
2599
+ if (images.length > 0) {
2600
+ item.images = images
2601
+ item.content = contentBlocks
2602
+ }
2603
+ last = item
1430
2604
  }
1431
2605
  if (last !== undefined) return { kind: 'prompt', ...last }
1432
2606
  return sawUserRole ? { kind: 'no-user-input' } : { kind: 'empty' }
@@ -1576,7 +2750,34 @@ export function acpPresetStatusFromRoster(rows: readonly RosterPresetRow[] | und
1576
2750
 
1577
2751
  // ---- Transcript ----
1578
2752
 
1579
- /** Bounded append-only log of raw ACP session updates with monotone seq. */
2753
+ /**
2754
+ * The kind of a state-only tick that consecutive repeats may share one entry.
2755
+ *
2756
+ * These are the frames the client folds to nothing (`STATE_ONLY_UPDATES`):
2757
+ * only their latest value is ever read. `session_info_update` qualifies only
2758
+ * while it carries no title — a title is content the replay must keep, so it
2759
+ * never lets a later tick replace its entry.
2760
+ */
2761
+ function coalescibleStateKind(update: unknown): string | undefined {
2762
+ if (typeof update !== 'object' || update === null || Array.isArray(update)) return undefined
2763
+ const row = update as Record<string, unknown>
2764
+ const kind = row.sessionUpdate
2765
+ if (kind === 'usage_update' || kind === 'config_option_update') return kind
2766
+ if (kind !== 'session_info_update') return undefined
2767
+ const title = row.title
2768
+ return title === undefined || title === null || title === '' ? kind : undefined
2769
+ }
2770
+
2771
+ /**
2772
+ * Bounded log of raw ACP session updates with monotone seq.
2773
+ *
2774
+ * Consecutive ticks of the same state-only kind share one entry (the entry is
2775
+ * replaced in place, not appended): chatty agents emit a usage / phase tick
2776
+ * every few hundred milliseconds, and letting each occupy the ring evicts the
2777
+ * conversation itself. This costs nothing on the live path — `append` still
2778
+ * allocates a fresh seq and the bridge broadcasts that frame — but replay via
2779
+ * `since` only sees the newest tick of each run.
2780
+ */
1580
2781
  export class Transcript {
1581
2782
  private readonly entries: Array<{ seq: number; update: unknown }> = []
1582
2783
  private next = 1
@@ -1585,6 +2786,16 @@ export class Transcript {
1585
2786
 
1586
2787
  append(update: unknown): number {
1587
2788
  const seq = this.next++
2789
+ const last = this.entries[this.entries.length - 1]
2790
+ const kind = coalescibleStateKind(update)
2791
+ // Replace, don't push: `since(afterSeq)` keeps its filter, so intermediate
2792
+ // seq values are simply absent from replayed entries. Live SSE frames are
2793
+ // broadcast per append (the returned seq), and the client's live fold does
2794
+ // not depend on transcript contiguity — only `head` must keep advancing.
2795
+ if (kind !== undefined && last !== undefined && coalescibleStateKind(last.update) === kind) {
2796
+ this.entries[this.entries.length - 1] = { seq, update }
2797
+ return seq
2798
+ }
1588
2799
  this.entries.push({ seq, update })
1589
2800
  if (this.entries.length > this.max) this.entries.splice(0, this.entries.length - this.max)
1590
2801
  return seq
@@ -1778,7 +2989,7 @@ export function permissionReplayFrames(pending: readonly PendingPermissionView[]
1778
2989
  export interface AcpManager {
1779
2990
  agentViews(): AgentView[]
1780
2991
  startSession(agentId: string, cwd: string | undefined): Promise<{ sessionId: string }>
1781
- prompt(sessionId: string, text: string): Promise<{ turnId: string }>
2992
+ prompt(sessionId: string, prompt: string | PromptContentBlock[]): Promise<{ turnId: string }>
1782
2993
  cancel(sessionId: string): Promise<void>
1783
2994
  closeSession(sessionId: string): Promise<void>
1784
2995
  setMode(sessionId: string, modeId: string): Promise<void>
@@ -2031,7 +3242,37 @@ export async function dispatchRpc(manager: AcpManager, endpoint: string, payload
2031
3242
  case 'session/start':
2032
3243
  return ok(await manager.startSession(str(body, 'agentId', 64), optionalStr(body, 'cwd', 4096)))
2033
3244
  case 'session/prompt': {
2034
- const { turnId } = await manager.prompt(str(body, 'sessionId', 64), str(body, 'text', 1 << 20))
3245
+ let promptInput: string | PromptContentBlock[]
3246
+ if (Array.isArray(body.prompt)) {
3247
+ if (body.prompt.length === 0) {
3248
+ throw new AcpError('bad_payload', 'acp: prompt array cannot be empty')
3249
+ }
3250
+ for (const b of body.prompt) {
3251
+ if (!b || typeof b !== 'object') throw new AcpError('bad_payload', 'acp: prompt blocks must be objects')
3252
+ const block = b as Record<string, unknown>
3253
+ if (block.type === 'text') {
3254
+ if (typeof block.text !== 'string') throw new AcpError('bad_payload', 'acp: text block requires string text')
3255
+ if (block.text.length > (1 << 20)) throw new AcpError('bad_payload', 'acp: text block exceeds max length')
3256
+ } else if (block.type === 'image') {
3257
+ if (typeof block.data !== 'string' || typeof block.mimeType !== 'string') {
3258
+ throw new AcpError('bad_payload', 'acp: image block requires string data and mimeType')
3259
+ }
3260
+ if (!block.mimeType.toLowerCase().startsWith('image/')) {
3261
+ throw new AcpError('bad_payload', 'acp: image block mimeType must start with image/')
3262
+ }
3263
+ if (block.uri !== undefined && typeof block.uri !== 'string') {
3264
+ throw new AcpError('bad_payload', 'acp: image block uri must be string')
3265
+ }
3266
+ if (block.data.length > (20 << 20)) throw new AcpError('bad_payload', 'acp: image block exceeds max data length')
3267
+ } else {
3268
+ throw new AcpError('bad_payload', `acp: unsupported prompt block type: ${String(block.type)}`)
3269
+ }
3270
+ }
3271
+ promptInput = body.prompt as PromptContentBlock[]
3272
+ } else {
3273
+ promptInput = str(body, 'text', 1 << 20)
3274
+ }
3275
+ const { turnId } = await manager.prompt(str(body, 'sessionId', 64), promptInput)
2035
3276
  return ok({ accepted: true as const, turnId })
2036
3277
  }
2037
3278
  case 'session/cancel':