dsh-cindy-host 0.1.38 → 0.1.40
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/CHANGELOG.md +12 -0
- package/README.md +2 -2
- package/package.json +1 -1
- package/src/cindy-message-row.js +16 -5
- package/src/cindy-session-row.js +5 -0
- package/src/dsh-plugin.js +8 -0
- package/src/host-attachment-media.js +97 -0
- package/src/host-attachment-reference.js +46 -0
- package/src/host-file-browser.js +8 -12
- package/src/host-file-thumbnail.js +67 -0
- package/src/host-frame-budget.js +27 -1
- package/src/host-media-fetch.js +1 -1
- package/src/host-version.js +1 -1
- package/src/host.js +15 -1
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,17 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 0.1.40 — 2026-11-07
|
|
4
|
+
|
|
5
|
+
- Declare `source: 'desktop'` consistently on ordinary DSH task rows in session lists and direct detail reads, independently of model/provider identity. Let Cindy recognize known ordinary-task provenance without requiring an extra source-confirmation lookup before mounting the chat.
|
|
6
|
+
- Preserve provenance when history counts are unavailable and keep missing tasks as explicit `NOT_FOUND` failures. Add projection/router regressions without changing authorization, source logs, provider selection, Cindy code or its cache implementation; handset re-entry/cache behavior remains separate acceptance.
|
|
7
|
+
|
|
8
|
+
## 0.1.39 — 2026-11-07
|
|
9
|
+
|
|
10
|
+
- Serve bounded, authorized project-grid WebP thumbnails through the existing file-browser contract; keep original-file reads independent of codec failures.
|
|
11
|
+
- Preserve native tool/assistant image blocks through deterministic Cindy managed-media references. Resolve only the DSH attachment namespace against visible source messages and full native descriptors (including omitted optional names), verify bytes/hash/MIME, and deliver bounded thumbnails or on-demand staged originals. Keep live/history identities, source/connection fences, retry/cache budgets and explicit missing-source failures; never infer upload authority from JSON localPath.
|
|
12
|
+
- Add Host regressions for generated PNG/JPEG media, legacy/canonical results, restart/history consistency, thumbnail resource/path boundaries and staging retries. No Cindy changes; publication, runtime activation and handset acceptance are separate verification steps.
|
|
13
|
+
- Classify newly observed companion-live/setup-snapshot/setup-transfer channels as unsupported with exact refusal regressions. This read-only audit maintenance enables no cloud-companion, configuration migration, secret transfer or SSH authorization capability.
|
|
14
|
+
|
|
3
15
|
## 0.1.38 — 2026-11-07
|
|
4
16
|
|
|
5
17
|
- Show a valid `run_code` description in Cindy's collapsed tool label as a Host-only display alias. Normalize the preview to one bounded line, remove control/bidi-spoofing characters, and cap it at 120 Unicode code points without changing original input or full details.
|
package/README.md
CHANGED
|
@@ -4,12 +4,12 @@ A Host-side adapter connecting current Cindy clients to DeepSeek Harness.
|
|
|
4
4
|
|
|
5
5
|
## Requirements and installation
|
|
6
6
|
|
|
7
|
-
Version **0.1.
|
|
7
|
+
Version **0.1.40** declares ordinary-task provenance consistently in session list/detail rows, independent of the model provider, so Cindy can recognize known DSH tasks without an additional source-confirmation lookup. It retains the earlier image delivery, thumbnails, work titles, history stability and interruption controls. It does not implement a new phone cache; handset re-entry, rendering and white-screen acceptance remain separate.
|
|
8
8
|
|
|
9
9
|
It supports the exact **DSH 0.2.2-alpha.2** carrier, while retaining `0.2.1-rc.3`, `0.2.1-rc.4` and `0.2.1-rc.5`. Alpha requires **Node.js >=24.9** (tested with 24.12); the older carriers retain this plugin's Node.js >=22.16 minimum. Agent/session/settings peers and alpha Schemastery are explicitly allowed, not a blanket promise for future prereleases. The full Web installation gate uses DSH **0.2.1-rc.5**; alpha core-contract checks do not establish alpha Web/relay/handset acceptance. Install through the Desktop DSH plugin manager. CLI-managed profiles can use:
|
|
10
10
|
|
|
11
11
|
```sh
|
|
12
|
-
dsh plugin --profile <profile> add dsh-cindy-host@0.1.
|
|
12
|
+
dsh plugin --profile <profile> add dsh-cindy-host@0.1.40
|
|
13
13
|
```
|
|
14
14
|
|
|
15
15
|
Reload the Host plugin after installation. A full DSH restart is separate. Keep existing Cindy sign-in, device identity and settings; do not run two Host processes with the same identity.
|
package/package.json
CHANGED
package/src/cindy-message-row.js
CHANGED
|
@@ -16,6 +16,8 @@
|
|
|
16
16
|
* tool_result content: anything previewable read by `contentToPreview`
|
|
17
17
|
*/
|
|
18
18
|
|
|
19
|
+
import { attachmentImageUrl, nativeMessageImages, toolResultWithImages } from './host-attachment-reference.js';
|
|
20
|
+
|
|
19
21
|
/**
|
|
20
22
|
* DSH message roles this Host renders, and the phone row role each becomes.
|
|
21
23
|
*
|
|
@@ -314,7 +316,7 @@ export function toCindyMessageRows(message, { sessionId, createdAt, now = () =>
|
|
|
314
316
|
const id = `${sessionId}:${message.id}:0`;
|
|
315
317
|
return [{ id, clientId: id, sessionId, role: 'tool_result', toolUseId: message.toolCallId, agentMeta: null,
|
|
316
318
|
createdAt: createdAt ?? now().toISOString(),
|
|
317
|
-
content: { text: flattenToolResult(message.content), isError: message.isError === true } }];
|
|
319
|
+
content: { text: toolResultWithImages(flattenToolResult(message.content), message, sessionId), isError: message.isError === true } }];
|
|
318
320
|
}
|
|
319
321
|
const role = ROW_ROLE_BY_MESSAGE_ROLE.get(message?.role);
|
|
320
322
|
// `system` (the prompt injection), a hook's injected turn, or any role this
|
|
@@ -346,10 +348,19 @@ export function toCindyMessageRows(message, { sessionId, createdAt, now = () =>
|
|
|
346
348
|
|
|
347
349
|
blocks.forEach((block, index) => {
|
|
348
350
|
if (block === null || typeof block !== 'object') return;
|
|
351
|
+
// Native assistant images use the same locator as tool images, not a fabricated user attachment.
|
|
352
|
+
if (role === 'assistant' && block.type === 'image') {
|
|
353
|
+
const image = nativeMessageImages(message).find(image => image.blockPath === String(index));
|
|
354
|
+
const url = image && attachmentImageUrl(sessionId, message.id, image.blockPath, image.ref);
|
|
355
|
+
if (url) {
|
|
356
|
+
const id = `${sessionId}:${message.id}:${index}`;
|
|
357
|
+
const label = (image.ref.name ?? 'image').replace(/[\[\]\r\n]/g, '_');
|
|
358
|
+
rows.push({ id, clientId: id, sessionId, role: 'assistant', toolUseId: null, agentMeta: null, createdAt: time, content: `` });
|
|
359
|
+
}
|
|
360
|
+
return;
|
|
361
|
+
}
|
|
349
362
|
const kind = ROLE_BY_BLOCK[block.type];
|
|
350
|
-
//
|
|
351
|
-
// channel this Host does not serve, and the file's chip travels on the text
|
|
352
|
-
// row above. Emitting them as empty rows would add noise, not information.
|
|
363
|
+
// User attachments travel on their text row; unknown blocks stay silent.
|
|
353
364
|
if (kind === undefined) return;
|
|
354
365
|
if (toolResultMessage && block.type !== 'tool-result') return;
|
|
355
366
|
|
|
@@ -414,7 +425,7 @@ export function toCindyMessageRows(message, { sessionId, createdAt, now = () =>
|
|
|
414
425
|
rows.push({
|
|
415
426
|
...base,
|
|
416
427
|
toolUseId: typeof block.toolCallId === 'string' ? block.toolCallId : null,
|
|
417
|
-
content: { text: flattenToolResult(block.content), isError: block.isError === true },
|
|
428
|
+
content: { text: toolResultWithImages(flattenToolResult(block.content), message, sessionId, index), isError: block.isError === true },
|
|
418
429
|
});
|
|
419
430
|
return;
|
|
420
431
|
default:
|
package/src/cindy-session-row.js
CHANGED
|
@@ -76,6 +76,11 @@ export function toCindySessionListRow(item, { now = () => new Date(), device, de
|
|
|
76
76
|
return {
|
|
77
77
|
id,
|
|
78
78
|
title,
|
|
79
|
+
// DSH projects ordinary user tasks, not Cindy Make/bot sessions. The phone's
|
|
80
|
+
// entry gate requires explicit provenance before mounting the chat and its
|
|
81
|
+
// local history cache; omitting it forces an extra remote lookup on re-entry.
|
|
82
|
+
// This is task provenance, independent of the model's providerId below.
|
|
83
|
+
source: 'desktop',
|
|
79
84
|
createdAt,
|
|
80
85
|
updatedAt,
|
|
81
86
|
// `'active' | 'archived' | 'deleted'`; the controller's default status filter
|
package/src/dsh-plugin.js
CHANGED
|
@@ -23,6 +23,7 @@ import { createHistoryViewController } from './host-history-view.js';
|
|
|
23
23
|
import { createFileReader } from './host-files.js';
|
|
24
24
|
import { createFileBrowser } from './host-file-browser.js';
|
|
25
25
|
import { createAttachmentMaterializer } from './host-attachments.js';
|
|
26
|
+
import { createNativeAttachmentSourceReader } from './host-attachment-media.js';
|
|
26
27
|
import { createGoalWriter } from './host-goals.js';
|
|
27
28
|
import { startHost } from './host.js';
|
|
28
29
|
import { refusalError } from './cindy-channels.js';
|
|
@@ -654,6 +655,11 @@ export function buildDshSource(ctx, serviceName, options = {}) {
|
|
|
654
655
|
return null;
|
|
655
656
|
}
|
|
656
657
|
};
|
|
658
|
+
const readAttachmentSource = createNativeAttachmentSourceReader({
|
|
659
|
+
getSession: sessionId => sessionSource.getSession(sessionId),
|
|
660
|
+
readSessionLog: sessionQuery === undefined ? undefined : sessionId => sessionQuery.readSession(sessionId),
|
|
661
|
+
readImage: readImageAttachment,
|
|
662
|
+
});
|
|
657
663
|
const readMessages = sessionQuery === undefined
|
|
658
664
|
? undefined
|
|
659
665
|
: createMessageReader({ readSessionLog: (sessionId) => sessionQuery.readSession(sessionId), readImageAttachment });
|
|
@@ -839,6 +845,7 @@ export function buildDshSource(ctx, serviceName, options = {}) {
|
|
|
839
845
|
// Reported through the status route: "the photo does not render" has three
|
|
840
846
|
// indistinguishable causes from the outside, and this separates them.
|
|
841
847
|
attachmentReads,
|
|
848
|
+
readAttachmentSource,
|
|
842
849
|
// Internal plumbing rather than a controller capability: the live push path has to inline a
|
|
843
850
|
// message's images before the row leaves, exactly like the two read paths do. It rides the
|
|
844
851
|
// seam so there is **one** hydrator (`hydrateRows`) instead of one per caller — a second
|
|
@@ -1485,6 +1492,7 @@ export function capabilityProvider(sources) {
|
|
|
1485
1492
|
// NOT_AVAILABLE while the controller sat right here (the `queueControl` failure).
|
|
1486
1493
|
historyView: sources.historyView,
|
|
1487
1494
|
attachmentReads: sources.attachmentReads,
|
|
1495
|
+
readAttachmentSource: sources.readAttachmentSource,
|
|
1488
1496
|
// Internal (the live push path), but it crosses the same seam, so it is forwarded here: the
|
|
1489
1497
|
// invariant test in `test/dsh-plugin.test.js` requires every seam key to be reachable, and a
|
|
1490
1498
|
// key that exists but is reachable from nowhere is exactly the bug class this repo keeps
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
import { createHash } from 'node:crypto';
|
|
2
|
+
import { deriveEventMessage, isAppendSurfaceEvent } from '@deepseek-ai/dsh-session/surface';
|
|
3
|
+
import { nativeMessageImages, attachmentImageUrl, parseAttachmentImageUrl, isImageDescriptor } from './host-attachment-reference.js';
|
|
4
|
+
import { renderBoundedThumbnail } from './host-file-thumbnail.js';
|
|
5
|
+
import { MEDIA_FETCH_MAX_BYTES, INLINE_ORIGINAL_MAX_BYTES, STAGING_CACHE_MAX, STAGING_CACHE_TTL_MS, isThumbnailableMime } from './host-media-fetch.js';
|
|
6
|
+
|
|
7
|
+
const failure = (code, message) => ({ ok: false, code, message });
|
|
8
|
+
|
|
9
|
+
/** Reconstruct from the authorized source log on every request, including after restart. */
|
|
10
|
+
export function createNativeAttachmentSourceReader({ getSession, readSessionLog, readImage }) {
|
|
11
|
+
return async locator => {
|
|
12
|
+
if (typeof getSession !== 'function' || typeof readSessionLog !== 'function' || typeof readImage !== 'function')
|
|
13
|
+
return failure('NOT_AVAILABLE', 'native attachment services unavailable');
|
|
14
|
+
try {
|
|
15
|
+
if (!await getSession(locator.sessionId)) return failure('FORBIDDEN', 'source session unavailable');
|
|
16
|
+
const log = await readSessionLog(locator.sessionId);
|
|
17
|
+
let ref;
|
|
18
|
+
for (const event of Array.isArray(log?.events) ? log.events : []) {
|
|
19
|
+
if (!event || !isAppendSurfaceEvent(event)) continue;
|
|
20
|
+
const message = deriveEventMessage(event);
|
|
21
|
+
if (message?.id !== locator.messageId) continue;
|
|
22
|
+
const image = nativeMessageImages(message).find(image => image.blockPath === locator.blockPath);
|
|
23
|
+
if (image?.ref.attachmentId === locator.attachmentId && image.ref.mediaType === locator.mediaType) ref = image.ref;
|
|
24
|
+
break;
|
|
25
|
+
}
|
|
26
|
+
if (!ref) return failure('MEDIA_SOURCE_MISSING', 'source image no longer available');
|
|
27
|
+
if (ref.bytes > MEDIA_FETCH_MAX_BYTES) return failure('BAD_REQUEST', 'source image exceeds media budget');
|
|
28
|
+
const stored = await readImage(ref);
|
|
29
|
+
if (!stored || typeof stored.base64 !== 'string') return failure('MEDIA_SOURCE_MISSING', 'source image no longer readable');
|
|
30
|
+
return { ok: true, ref, bytes: Buffer.from(stored.base64, 'base64') };
|
|
31
|
+
} catch { return failure('MEDIA_SOURCE_MISSING', 'source image no longer readable'); }
|
|
32
|
+
};
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/** These URLs do not name paths, so they never enter the arbitrary local-file resolver. */
|
|
36
|
+
export function createAttachmentMediaFetcher({ readSource, uploader, render = renderBoundedThumbnail, now = Date.now, maxBytes = MEDIA_FETCH_MAX_BYTES, getScope = () => 0, isAllowed = () => true, maxConcurrent = 4 } = {}) {
|
|
37
|
+
const staged = new Map();
|
|
38
|
+
const flights = new Map();
|
|
39
|
+
const inline = (mimeType, bytes) => ({ ok: true, result: { ossKey: '', mimeType, size: bytes.length, inlineBase64: bytes.toString('base64') } });
|
|
40
|
+
let active = 0;
|
|
41
|
+
async function fetchImage(input) {
|
|
42
|
+
const locator = parseAttachmentImageUrl(input?.url);
|
|
43
|
+
if (!locator) return failure('BAD_REQUEST', 'invalid DSH attachment image locator');
|
|
44
|
+
const scope = getScope();
|
|
45
|
+
const current = () => scope !== null && getScope() === scope && isAllowed(locator);
|
|
46
|
+
const superseded = () => failure('FORBIDDEN', 'image request authorization changed');
|
|
47
|
+
if (!current()) return superseded();
|
|
48
|
+
if (typeof readSource !== 'function') return failure('NOT_AVAILABLE', 'native attachment reader unavailable');
|
|
49
|
+
// Authorization and current source existence are checked even on a staging-cache hit.
|
|
50
|
+
let source;
|
|
51
|
+
try { source = await readSource(locator); } catch { return failure('MEDIA_SOURCE_MISSING', 'source image unreadable'); }
|
|
52
|
+
if (!current()) return superseded();
|
|
53
|
+
if (!source?.ok) return source ?? failure('MEDIA_SOURCE_MISSING', 'source image unavailable');
|
|
54
|
+
const { ref, bytes } = source;
|
|
55
|
+
if (!isImageDescriptor(ref) || attachmentImageUrl(locator.sessionId, locator.messageId, locator.blockPath, ref) !== input.url
|
|
56
|
+
|| !Buffer.isBuffer(bytes) || bytes.length !== ref.bytes
|
|
57
|
+
|| `sha256:${createHash('sha256').update(bytes).digest('hex')}` !== ref.attachmentId)
|
|
58
|
+
return failure('MEDIA_SOURCE_MISSING', 'source image descriptor mismatch');
|
|
59
|
+
if (bytes.length > maxBytes) return failure('BAD_REQUEST', 'source image exceeds media budget');
|
|
60
|
+
if (input?.prepareOnly === true && input?.thumbnail !== true)
|
|
61
|
+
return failure('NOT_AVAILABLE', 'native attachment images use media fetch, not file-peer');
|
|
62
|
+
const cacheKey = `${scope}:${ref.attachmentId}`;
|
|
63
|
+
const key = `${cacheKey}:${input?.thumbnail === true ? 'thumbnail' : 'original'}:${input?.skipCache === true ? 'refresh' : 'cached'}`;
|
|
64
|
+
if (flights.has(key)) {
|
|
65
|
+
const result = await flights.get(key);
|
|
66
|
+
return current() ? result : superseded();
|
|
67
|
+
}
|
|
68
|
+
const work = Promise.resolve().then(async () => {
|
|
69
|
+
if (!current()) return superseded();
|
|
70
|
+
if (input?.thumbnail === true) {
|
|
71
|
+
let thumb;
|
|
72
|
+
try { thumb = isThumbnailableMime(ref.mediaType) ? await render(bytes) : null; } catch { thumb = null; }
|
|
73
|
+
if (!current()) return superseded();
|
|
74
|
+
if (thumb?.ok && Buffer.isBuffer(thumb.data) && thumb.data.length > 0 && thumb.data.length <= 700 * 1024) return inline('image/webp', thumb.data);
|
|
75
|
+
if (bytes.length <= INLINE_ORIGINAL_MAX_BYTES) return inline(ref.mediaType, bytes);
|
|
76
|
+
}
|
|
77
|
+
if (typeof uploader !== 'function') return failure('NOT_AVAILABLE', 'media staging unavailable');
|
|
78
|
+
const cached = staged.get(cacheKey);
|
|
79
|
+
if (input?.skipCache !== true && cached && cached.at + STAGING_CACHE_TTL_MS > now()) return { ok: true, result: { ...cached.result } };
|
|
80
|
+
if (!current()) return superseded();
|
|
81
|
+
const result = await uploader(bytes, { ext: locator.ext, contentType: ref.mediaType });
|
|
82
|
+
if (!current()) return superseded();
|
|
83
|
+
if (!result?.ok || typeof result.key !== 'string' || !result.key) return failure('INTERNAL', 'image staging failed; retry later');
|
|
84
|
+
const answer = { ossKey: result.key, mimeType: ref.mediaType, size: bytes.length };
|
|
85
|
+
staged.set(cacheKey, { at: now(), result: answer });
|
|
86
|
+
while (staged.size > STAGING_CACHE_MAX) staged.delete(staged.keys().next().value);
|
|
87
|
+
return { ok: true, result: answer };
|
|
88
|
+
}).catch(() => failure('INTERNAL', 'image staging failed; retry later')).finally(() => flights.delete(key));
|
|
89
|
+
flights.set(key, work);
|
|
90
|
+
return work;
|
|
91
|
+
}
|
|
92
|
+
return async input => {
|
|
93
|
+
if (active >= maxConcurrent) return failure('INTERNAL', 'image reader busy; retry later');
|
|
94
|
+
active++;
|
|
95
|
+
try { return await fetchImage(input); } finally { active--; }
|
|
96
|
+
};
|
|
97
|
+
}
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
/** Deterministic, non-bearer locators. No path or caller-supplied descriptor is admitted. */
|
|
2
|
+
const EXT = Object.freeze({ 'image/png': 'png', 'image/jpeg': 'jpg', 'image/webp': 'webp', 'image/gif': 'gif' });
|
|
3
|
+
const token = value => Buffer.from(value, 'utf8').toString('base64url');
|
|
4
|
+
export function isImageDescriptor(ref) {
|
|
5
|
+
return ref && /^sha256:[a-f0-9]{64}$/.test(ref.attachmentId) && Object.hasOwn(EXT, ref.mediaType)
|
|
6
|
+
&& Number.isSafeInteger(ref.bytes) && ref.bytes > 0
|
|
7
|
+
&& Number.isSafeInteger(ref.width) && ref.width > 0 && Number.isSafeInteger(ref.height) && ref.height > 0
|
|
8
|
+
&& (ref.name === undefined || typeof ref.name === 'string');
|
|
9
|
+
}
|
|
10
|
+
export function attachmentImageUrl(sessionId, messageId, blockPath, ref) {
|
|
11
|
+
if (![sessionId, messageId].every(v => typeof v === 'string' && v.length > 0 && v.length <= 256)
|
|
12
|
+
|| !/^(0|[1-9][0-9]{0,5})(\.(0|[1-9][0-9]{0,5}))?$/.test(blockPath) || !isImageDescriptor(ref)) return null;
|
|
13
|
+
return `xdt-image://dsh-attachment/v1/${token(sessionId)}/${token(messageId)}/${blockPath}/${ref.attachmentId.slice(7)}.${EXT[ref.mediaType]}`;
|
|
14
|
+
}
|
|
15
|
+
export function parseAttachmentImageUrl(url) {
|
|
16
|
+
if (typeof url !== 'string' || url.length > 2048) return null;
|
|
17
|
+
const match = /^xdt-image:\/\/dsh-attachment\/v1\/([A-Za-z0-9_-]+)\/([A-Za-z0-9_-]+)\/((?:0|[1-9][0-9]{0,5})(?:\.(?:0|[1-9][0-9]{0,5}))?)\/([a-f0-9]{64})\.(png|jpg|webp|gif)$/.exec(url);
|
|
18
|
+
if (!match) return null;
|
|
19
|
+
const [, sid, mid, blockPath, hash, ext] = match;
|
|
20
|
+
const sessionId = Buffer.from(sid, 'base64url').toString('utf8');
|
|
21
|
+
const messageId = Buffer.from(mid, 'base64url').toString('utf8');
|
|
22
|
+
if (!sessionId || !messageId || sessionId.length > 256 || messageId.length > 256 || token(sessionId) !== sid || token(messageId) !== mid) return null;
|
|
23
|
+
return { sessionId, messageId, blockPath, attachmentId: `sha256:${hash}`, mediaType: Object.keys(EXT).find(mime => EXT[mime] === ext), ext };
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/** Only native image blocks in renderable model/tool messages, never JSON text. */
|
|
27
|
+
export function nativeMessageImages(message) {
|
|
28
|
+
const source = message?.source?.kind;
|
|
29
|
+
const direct = message?.role === 'tool' && (!source || source === 'tool')
|
|
30
|
+
|| message?.role === 'assistant' && (!source || source === 'model');
|
|
31
|
+
const legacy = message?.role === 'user' && (!source || source === 'tool');
|
|
32
|
+
const found = [];
|
|
33
|
+
for (const [index, block] of (Array.isArray(message?.content) ? message.content : []).entries()) {
|
|
34
|
+
if (direct && block?.type === 'image' && isImageDescriptor(block.attachment)) found.push({ blockPath: String(index), ref: block.attachment });
|
|
35
|
+
if (legacy && block?.type === 'tool-result' && Array.isArray(block.content)) {
|
|
36
|
+
for (const [child, nested] of block.content.entries())
|
|
37
|
+
if (nested?.type === 'image' && isImageDescriptor(nested.attachment)) found.push({ blockPath: `${index}.${child}`, ref: nested.attachment });
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
return found;
|
|
41
|
+
}
|
|
42
|
+
export function toolResultWithImages(text, message, sessionId, resultIndex = null) {
|
|
43
|
+
const urls = nativeMessageImages(message).filter(image => resultIndex === null || image.blockPath.startsWith(`${resultIndex}.`))
|
|
44
|
+
.map(image => attachmentImageUrl(sessionId, message.id, image.blockPath, image.ref)).filter(Boolean);
|
|
45
|
+
return urls.length ? JSON.stringify({ text, xdt_image_urls: urls }) : text;
|
|
46
|
+
}
|
package/src/host-file-browser.js
CHANGED
|
@@ -17,6 +17,8 @@
|
|
|
17
17
|
* reference host returns for an op it does not know.
|
|
18
18
|
*/
|
|
19
19
|
|
|
20
|
+
import { fileThumbnail } from './host-file-thumbnail.js';
|
|
21
|
+
|
|
20
22
|
/** Longest file this Host will inline for `readFile`. */
|
|
21
23
|
export const READ_LIMIT_BYTES = 1024 * 1024;
|
|
22
24
|
|
|
@@ -147,7 +149,7 @@ function looksBinary(text) {
|
|
|
147
149
|
* @param options - the filesystem service, plus injectable limits.
|
|
148
150
|
* @returns `(args) => Promise<unknown>`, one result per op.
|
|
149
151
|
*/
|
|
150
|
-
export function createFileBrowser({ fileSystem, readLimitBytes = READ_LIMIT_BYTES, listAllLimit = LIST_ALL_LIMIT }) {
|
|
152
|
+
export function createFileBrowser({ fileSystem, readLimitBytes = READ_LIMIT_BYTES, listAllLimit = LIST_ALL_LIMIT, thumbnail = fileThumbnail }) {
|
|
151
153
|
if (fileSystem === undefined || fileSystem === null) throw new Error('createFileBrowser requires a filesystem service');
|
|
152
154
|
|
|
153
155
|
/** Resolve a workdir-relative path, or null when it escapes or cannot resolve. */
|
|
@@ -379,17 +381,11 @@ export function createFileBrowser({ fileSystem, readLimitBytes = READ_LIMIT_BYTE
|
|
|
379
381
|
// reason* answer in their own documented shape, so the controller degrades
|
|
380
382
|
// deliberately instead of showing a transport-looking failure.
|
|
381
383
|
//
|
|
382
|
-
//
|
|
383
|
-
//
|
|
384
|
-
//
|
|
385
|
-
//
|
|
386
|
-
|
|
387
|
-
// Host does serve (there it fetches what the phone uploaded). Uploading on
|
|
388
|
-
// demand, with the progress polling `exportFileStatus` implies, is a transfer
|
|
389
|
-
// manager rather than a read, and is not served.
|
|
390
|
-
if (op === 'thumbnail') {
|
|
391
|
-
return { ok: false, code: 'THUMB_UNSUPPORTED', message: 'this Host generates no thumbnails' };
|
|
392
|
-
}
|
|
384
|
+
// Thumbnail failures retain Cindy's THUMB_* result shape; original file reads
|
|
385
|
+
// remain independent of codec availability.
|
|
386
|
+
// Export jobs are intercepted by cindy-channels before reaching this reader;
|
|
387
|
+
// a standalone reader has no transfer manager and refuses those operations.
|
|
388
|
+
if (op === 'thumbnail') return thumbnail({ workdir, relPath });
|
|
393
389
|
if (op === 'exportFileStart' || op === 'exportFileStatus') {
|
|
394
390
|
return { ok: false, message: 'file export needs the Cindy media pipeline, which this Host does not serve' };
|
|
395
391
|
}
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
import { createLocalFileResolver, readPreparedInline, resolveThumbnailCodec } from './host-media-fetch.js';
|
|
2
|
+
import { createLocalFileUrl } from './host-file-reference.js';
|
|
3
|
+
|
|
4
|
+
export const GRID_THUMBNAIL_LIMITS = Object.freeze({ edge: 256, quality: 70, outputBytes: 128 * 1024 });
|
|
5
|
+
export const IMAGE_INPUT_BYTES = 48 * 1024 * 1024;
|
|
6
|
+
export const IMAGE_INPUT_PIXELS = 40_000_000;
|
|
7
|
+
|
|
8
|
+
const failed = (code, message) => ({ ok: false, code, message });
|
|
9
|
+
|
|
10
|
+
/** Soft deadlines do not release the concurrency slot until native work actually settles. */
|
|
11
|
+
export function createThumbnailRenderer({ codec = resolveThumbnailCodec, concurrency = 2, timeoutMs = 5000 } = {}) {
|
|
12
|
+
let active = 0;
|
|
13
|
+
let loaded;
|
|
14
|
+
return async function render(bytes, { edge = 1024, quality = 80, outputBytes = 700 * 1024 } = {}) {
|
|
15
|
+
if (!Buffer.isBuffer(bytes) || bytes.length === 0 || bytes.length > IMAGE_INPUT_BYTES)
|
|
16
|
+
return failed('THUMB_TOO_LARGE', 'invalid or oversized input');
|
|
17
|
+
if (active >= concurrency) return failed('THUMB_FAILED', 'thumbnail renderer busy; retry later');
|
|
18
|
+
try { loaded ??= codec(); } catch { return failed('THUMB_UNSUPPORTED', 'image codec unavailable'); }
|
|
19
|
+
if (typeof loaded !== 'function') return failed('THUMB_UNSUPPORTED', 'image codec unavailable');
|
|
20
|
+
active++;
|
|
21
|
+
let timer;
|
|
22
|
+
const work = Promise.resolve().then(async () => {
|
|
23
|
+
const out = await loaded(bytes, { limitInputPixels: IMAGE_INPUT_PIXELS, failOn: 'error', animated: false })
|
|
24
|
+
.rotate().resize({ width: edge, height: edge, fit: 'inside', withoutEnlargement: true })
|
|
25
|
+
.webp({ quality }).toBuffer({ resolveWithObject: true });
|
|
26
|
+
if (!Buffer.isBuffer(out?.data) || !out.data.length || out.data.length > outputBytes)
|
|
27
|
+
return failed('THUMB_TOO_LARGE', 'thumbnail exceeds output budget');
|
|
28
|
+
if (![out.info?.width, out.info?.height].every(n => Number.isInteger(n) && n > 0 && n <= edge))
|
|
29
|
+
return failed('THUMB_FAILED', 'invalid thumbnail dimensions');
|
|
30
|
+
return { ok: true, data: out.data, width: out.info.width, height: out.info.height, mimeType: 'image/webp' };
|
|
31
|
+
}).catch(() => failed('THUMB_FAILED', 'image could not be decoded')).finally(() => { active--; });
|
|
32
|
+
try {
|
|
33
|
+
return await Promise.race([work, new Promise(resolve => {
|
|
34
|
+
timer = setTimeout(() => resolve(failed('THUMB_FAILED', 'thumbnail render timeout')), timeoutMs);
|
|
35
|
+
})]);
|
|
36
|
+
} finally { clearTimeout(timer); }
|
|
37
|
+
};
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export const renderBoundedThumbnail = createThumbnailRenderer();
|
|
41
|
+
|
|
42
|
+
/** Local-only file browser thumbnails reuse the exact authorization boundary of fileUrl. */
|
|
43
|
+
export function createFileThumbnail({
|
|
44
|
+
resolveFile = createLocalFileResolver({ maxBytes: IMAGE_INPUT_BYTES }),
|
|
45
|
+
readBytes = readPreparedInline, render = renderBoundedThumbnail,
|
|
46
|
+
} = {}) {
|
|
47
|
+
const fileUrl = createLocalFileUrl({ resolveFile });
|
|
48
|
+
let active = 0;
|
|
49
|
+
return async function thumbnail({ workdir, relPath } = {}) {
|
|
50
|
+
if (active >= 2) return failed('THUMB_FAILED', 'thumbnail reader busy; retry later');
|
|
51
|
+
active++;
|
|
52
|
+
try {
|
|
53
|
+
const reference = await fileUrl({ workdir, relPath });
|
|
54
|
+
if (!reference.ok) return failed(reference.code === 'BAD_REQUEST' && /byte limit/.test(reference.message) ? 'THUMB_TOO_LARGE' : 'THUMB_FAILED', 'image path is unavailable, not allowed or too large');
|
|
55
|
+
const source = await resolveFile(reference.url);
|
|
56
|
+
if (!source.ok) return failed('THUMB_FAILED', 'image path is unavailable or not allowed');
|
|
57
|
+
if (source.info.size > IMAGE_INPUT_BYTES) return failed('THUMB_TOO_LARGE', 'image exceeds input budget');
|
|
58
|
+
const bytes = await readBytes(source.real, source.info);
|
|
59
|
+
const result = await render(bytes, GRID_THUMBNAIL_LIMITS);
|
|
60
|
+
if (!result.ok) return result;
|
|
61
|
+
return { ok: true, dataBase64: result.data.toString('base64'), mimeType: result.mimeType, width: result.width, height: result.height };
|
|
62
|
+
} catch { return failed('THUMB_FAILED', 'image changed or could not be read'); }
|
|
63
|
+
finally { active--; }
|
|
64
|
+
};
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
export const fileThumbnail = createFileThumbnail();
|
package/src/host-frame-budget.js
CHANGED
|
@@ -31,6 +31,8 @@
|
|
|
31
31
|
* character, and a page of base64 images is 4/3 of their size.
|
|
32
32
|
*/
|
|
33
33
|
|
|
34
|
+
import { parseAttachmentImageUrl } from './host-attachment-reference.js';
|
|
35
|
+
|
|
34
36
|
/**
|
|
35
37
|
* The relay's hard frame ceiling, mirrored from `@cindy/device-link-protocol`.
|
|
36
38
|
*
|
|
@@ -119,6 +121,30 @@ export function cutToBytes(text, limit) {
|
|
|
119
121
|
return { text: `${text.slice(0, cut)}${TRUNCATION_SUFFIX}`, cut: true };
|
|
120
122
|
}
|
|
121
123
|
|
|
124
|
+
/** Keep Host-derived media JSON parseable when long tool text is compacted. */
|
|
125
|
+
function cutManagedImageResult(text, limit) {
|
|
126
|
+
if (typeof text !== 'string' || !text.includes('xdt_image_urls') || byteLength(text) <= limit) return null;
|
|
127
|
+
let parsed;
|
|
128
|
+
try { parsed = JSON.parse(text); } catch { return null; }
|
|
129
|
+
if (typeof parsed?.text !== 'string' || !Array.isArray(parsed.xdt_image_urls) || !parsed.xdt_image_urls.length
|
|
130
|
+
|| Object.keys(parsed).some(key => key !== 'text' && key !== 'xdt_image_urls')
|
|
131
|
+
|| !parsed.xdt_image_urls.every(url => parseAttachmentImageUrl(url))) return null;
|
|
132
|
+
const urls = [...parsed.xdt_image_urls];
|
|
133
|
+
const encode = body => JSON.stringify({ text: body, xdt_image_urls: urls });
|
|
134
|
+
const suffix = () => TRUNCATION_SUFFIX + (urls.length < parsed.xdt_image_urls.length ? `\n[${parsed.xdt_image_urls.length - urls.length} image references omitted]` : '');
|
|
135
|
+
while (urls.length && byteLength(encode(suffix())) > limit) urls.pop();
|
|
136
|
+
let low = 0; let high = parsed.text.length;
|
|
137
|
+
const prefix = length => {
|
|
138
|
+
const value = parsed.text.slice(0, length);
|
|
139
|
+
return /[\uD800-\uDBFF]$/.test(value) ? value.slice(0, -1) : value;
|
|
140
|
+
};
|
|
141
|
+
while (low < high) {
|
|
142
|
+
const middle = Math.ceil((low + high) / 2);
|
|
143
|
+
if (byteLength(encode(prefix(middle) + suffix())) <= limit) low = middle; else high = middle - 1;
|
|
144
|
+
}
|
|
145
|
+
return { text: encode(prefix(low) + suffix()), cut: true };
|
|
146
|
+
}
|
|
147
|
+
|
|
122
148
|
/** `agentMeta` with the truncation witness merged in, as the reference marks it. */
|
|
123
149
|
function markContentTruncated(agentMeta) {
|
|
124
150
|
const base = agentMeta !== null && typeof agentMeta === 'object' && !Array.isArray(agentMeta) ? agentMeta : {};
|
|
@@ -152,7 +178,7 @@ export function compactRowText(rows) {
|
|
|
152
178
|
}
|
|
153
179
|
if (content === null || typeof content !== 'object' || Array.isArray(content)) return row;
|
|
154
180
|
if (row.role === 'tool_result' || row.role === 'tool-use') {
|
|
155
|
-
const result = cutToBytes(content.text, TOOL_RESULT_LIMIT);
|
|
181
|
+
const result = cutManagedImageResult(content.text, TOOL_RESULT_LIMIT) ?? cutToBytes(content.text, TOOL_RESULT_LIMIT);
|
|
156
182
|
if (result.cut) {
|
|
157
183
|
changed = true;
|
|
158
184
|
return { ...row, content: { ...content, text: result.text }, agentMeta: markContentTruncated(row.agentMeta) };
|
package/src/host-media-fetch.js
CHANGED
|
@@ -349,7 +349,7 @@ function sameFileVersion(before, after) {
|
|
|
349
349
|
}
|
|
350
350
|
|
|
351
351
|
/** Read at most the authorized size plus one sentinel byte, even if the file grows. */
|
|
352
|
-
async function readPreparedInline(real, info, open, stat) {
|
|
352
|
+
export async function readPreparedInline(real, info, open = openImpl, stat = statImpl) {
|
|
353
353
|
const file = await open(real, fsConstants.O_RDONLY | (fsConstants.O_NONBLOCK ?? 0) | (fsConstants.O_NOFOLLOW ?? 0));
|
|
354
354
|
try {
|
|
355
355
|
if (!sameFileVersion(info, await file.stat())) throw new Error('file changed');
|
package/src/host-version.js
CHANGED
package/src/host.js
CHANGED
|
@@ -49,6 +49,7 @@ import { MAX_ATTACHMENT_BYTES } from './host-attachments.js';
|
|
|
49
49
|
import { createMediaRefResolver, createMediaReleaser, createMediaUploader, mediaApiBaseUrl, isAttachmentOssRef } from './host-media.js';
|
|
50
50
|
import { createLocalMediaFetcher, createLocalFileResolver, isBlockedMediaPath, isInsideDirectory, mimeForMediaPath } from './host-media-fetch.js';
|
|
51
51
|
import { createFilePeerManager } from './host-file-peer.js';
|
|
52
|
+
import { createAttachmentMediaFetcher } from './host-attachment-media.js';
|
|
52
53
|
import { createFileIceLoader } from './host-file-ice.js';
|
|
53
54
|
import { createLocalFileUrl } from './host-file-reference.js';
|
|
54
55
|
import { FILE_PEER_MAX_BYTES } from './file-peer-protocol.js';
|
|
@@ -256,7 +257,20 @@ export async function startHost(initialSource, settings = DEFAULT_HOST_SETTINGS,
|
|
|
256
257
|
* uploader as the file browser's export ops, stricter request handling
|
|
257
258
|
* (`host-media-fetch.js`): a containment root is mandatory and both sides are realpath'd.
|
|
258
259
|
*/
|
|
259
|
-
const
|
|
260
|
+
const fetchFileMedia = createLocalMediaFetcher({ uploader: uploadMedia });
|
|
261
|
+
const fetchAttachmentMedia = createAttachmentMediaFetcher({
|
|
262
|
+
uploader: uploadMedia,
|
|
263
|
+
getScope: () => stopped ? null : `${generation}:${sourceRevision}`,
|
|
264
|
+
isAllowed: locator => !sessionIsHidden(locator.sessionId),
|
|
265
|
+
readSource: async locator => {
|
|
266
|
+
if (sessionIsHidden(locator.sessionId)) return { ok: false, code: 'FORBIDDEN', message: 'source session unavailable' };
|
|
267
|
+
const capabilities = typeof options.resolveCapabilities === 'function' ? options.resolveCapabilities() : {};
|
|
268
|
+
return typeof capabilities.readAttachmentSource === 'function'
|
|
269
|
+
? capabilities.readAttachmentSource(locator)
|
|
270
|
+
: { ok: false, code: 'NOT_AVAILABLE', message: 'native attachment reader unavailable' };
|
|
271
|
+
} });
|
|
272
|
+
const fetchLocalMedia = input => typeof input?.url === 'string' && input.url.startsWith('xdt-image://')
|
|
273
|
+
? fetchAttachmentMedia(input) : fetchFileMedia(input);
|
|
260
274
|
const resolvePeerFile = createLocalFileResolver({ maxBytes: FILE_PEER_MAX_BYTES });
|
|
261
275
|
const fileUrl = createLocalFileUrl({ resolveFile: resolvePeerFile });
|
|
262
276
|
|