dsh-cindy-host 0.1.37 → 0.1.39
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 +6 -2
- package/package.json +1 -1
- package/src/cindy-message-row.js +41 -10
- 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.39 — 2026-11-07
|
|
4
|
+
|
|
5
|
+
- Serve bounded, authorized project-grid WebP thumbnails through the existing file-browser contract; keep original-file reads independent of codec failures.
|
|
6
|
+
- 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.
|
|
7
|
+
- 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.
|
|
8
|
+
- 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.
|
|
9
|
+
|
|
10
|
+
## 0.1.38 — 2026-11-07
|
|
11
|
+
|
|
12
|
+
- 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.
|
|
13
|
+
- Keep execution tool names, call/result identities, other tools and exact-name special cards unchanged. Share the projection between live and history reads; invalid/missing descriptions retain the original name. Add focused display/input-immutability/pairing regressions; handset rendering remains separate acceptance.
|
|
14
|
+
|
|
3
15
|
## 0.1.37 — 2026-11-06
|
|
4
16
|
|
|
5
17
|
- Use explicit source-order history snapshots for both validated persistence and the hot fallback reader. Preserve legacy raw order, reconcile live reasoning through native history-live client identities, and page by source cuts; unknown cursors now fail explicitly rather than silently skipping history.
|
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.39** adds bounded project-grid thumbnails and native tool/assistant image delivery through authorized managed-media references. It retains the earlier tool work titles, history stability and interruption controls. Thumbnail decoding reuses the Host's image codec; codec failure does not block independent original-file reads. Host regressions do not establish handset rendering or white-screen acceptance.
|
|
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.39
|
|
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.
|
|
@@ -95,6 +95,10 @@ Organization ID/slug/domain and email discovery check both regions. A found orga
|
|
|
95
95
|
|
|
96
96
|
This feature ships in 0.1.33; installation and runtime activation are verified separately. Tests cover protocol, real local callbacks, settings-panel state, credential races and both regions' actual runtime URL routing with fake credentials. Real Apple/enterprise identity providers and iOS discovery/file round trips still require an authorized test account after deployment. See [authentication flow notes](doc/auth-login.md). The new browser flow is exposed by the settings page; the older interactive CLI is not a replacement for that UI on Windows.
|
|
97
97
|
|
|
98
|
+
## Tool work titles on Cindy mobile (0.1.38)
|
|
99
|
+
|
|
100
|
+
The Host projects `run_code` calls with a non-empty string `description` as `run_code · <description>` in Cindy’s collapsed tool row. Titles are normalized to one line and limited to a 120-code-point preview; the original input, code and full description remain in expanded details. Calls without a valid title and all other tool names keep their existing mapping. Live events and history use the same projection, and results remain paired by `toolUseId`. This is a Host-only display alias, not a renamed DSH execution tool. Reload an updated Host to apply it; existing phone-cached history may need reloading. Handset rendering has not been acceptance-tested.
|
|
101
|
+
|
|
98
102
|
## Thinking content on Cindy mobile (0.1.33)
|
|
99
103
|
|
|
100
104
|
The Host sends DSH-provided public reasoning text/summaries as native structured `thinking` content (`{ text }`), not ordinary prose strings. Expand the work/activity group, then its thinking row, to read it. No Cindy or DSH core patch is needed. Models that expose no reasoning text still have nothing to show; signatures and provider-private replay metadata are never forwarded.
|
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
|
*
|
|
@@ -69,8 +71,26 @@ function parseToolArguments(raw) {
|
|
|
69
71
|
*/
|
|
70
72
|
const TOOL_NAME_BY_DSH_NAME = { todo_write: 'TodoWrite' };
|
|
71
73
|
|
|
72
|
-
/**
|
|
73
|
-
|
|
74
|
+
/**
|
|
75
|
+
* Project a tool name for Cindy's read-only transcript, never for execution.
|
|
76
|
+
* Mobile's generic tool row uses toolName as its collapsed label and leaves
|
|
77
|
+
* input.description in expanded details. Only run_code needs this display alias;
|
|
78
|
+
* special card names (notably TodoWrite) must retain their exact vocabulary.
|
|
79
|
+
*/
|
|
80
|
+
function cindyToolName(name, input) {
|
|
81
|
+
if (name === 'run_code' && input !== null && typeof input === 'object'
|
|
82
|
+
&& !Array.isArray(input) && typeof input.description === 'string') {
|
|
83
|
+
// One bounded line, without control/bidi characters that could spoof the name.
|
|
84
|
+
// Keep the original input intact for expanded details and result pairing by ID.
|
|
85
|
+
const title = input.description
|
|
86
|
+
.replace(/[\u0000-\u001f\u007f-\u009f\u200e\u200f\u202a-\u202e\u2066-\u2069]/g, ' ')
|
|
87
|
+
.replace(/\s+/gu, ' ').trim();
|
|
88
|
+
const characters = Array.from(title);
|
|
89
|
+
if (characters.length > 0) {
|
|
90
|
+
const preview = characters.length > 120 ? `${characters.slice(0, 119).join('')}…` : title;
|
|
91
|
+
return `${name} · ${preview}`;
|
|
92
|
+
}
|
|
93
|
+
}
|
|
74
94
|
return TOOL_NAME_BY_DSH_NAME[name] ?? name;
|
|
75
95
|
}
|
|
76
96
|
|
|
@@ -296,7 +316,7 @@ export function toCindyMessageRows(message, { sessionId, createdAt, now = () =>
|
|
|
296
316
|
const id = `${sessionId}:${message.id}:0`;
|
|
297
317
|
return [{ id, clientId: id, sessionId, role: 'tool_result', toolUseId: message.toolCallId, agentMeta: null,
|
|
298
318
|
createdAt: createdAt ?? now().toISOString(),
|
|
299
|
-
content: { text: flattenToolResult(message.content), isError: message.isError === true } }];
|
|
319
|
+
content: { text: toolResultWithImages(flattenToolResult(message.content), message, sessionId), isError: message.isError === true } }];
|
|
300
320
|
}
|
|
301
321
|
const role = ROW_ROLE_BY_MESSAGE_ROLE.get(message?.role);
|
|
302
322
|
// `system` (the prompt injection), a hook's injected turn, or any role this
|
|
@@ -328,10 +348,19 @@ export function toCindyMessageRows(message, { sessionId, createdAt, now = () =>
|
|
|
328
348
|
|
|
329
349
|
blocks.forEach((block, index) => {
|
|
330
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
|
+
}
|
|
331
362
|
const kind = ROLE_BY_BLOCK[block.type];
|
|
332
|
-
//
|
|
333
|
-
// channel this Host does not serve, and the file's chip travels on the text
|
|
334
|
-
// row above. Emitting them as empty rows would add noise, not information.
|
|
363
|
+
// User attachments travel on their text row; unknown blocks stay silent.
|
|
335
364
|
if (kind === undefined) return;
|
|
336
365
|
if (toolResultMessage && block.type !== 'tool-result') return;
|
|
337
366
|
|
|
@@ -379,22 +408,24 @@ export function toCindyMessageRows(message, { sessionId, createdAt, now = () =>
|
|
|
379
408
|
? `${sessionId}:reasoning-stream:${identity.turn}:${identity.step}:${index}` : clientId;
|
|
380
409
|
rows.push({ ...base, clientId: logical, content: { text: block.text } });
|
|
381
410
|
return;
|
|
382
|
-
case 'tool-call':
|
|
411
|
+
case 'tool-call': {
|
|
412
|
+
const input = parseToolArguments(block.arguments);
|
|
383
413
|
rows.push({
|
|
384
414
|
...base,
|
|
385
415
|
toolUseId: typeof block.id === 'string' ? block.id : null,
|
|
386
416
|
content: {
|
|
387
|
-
toolName: typeof block.name === 'string' ? cindyToolName(block.name) : '',
|
|
388
|
-
input
|
|
417
|
+
toolName: typeof block.name === 'string' ? cindyToolName(block.name, input) : '',
|
|
418
|
+
input,
|
|
389
419
|
...(typeof block.id === 'string' ? { toolUseId: block.id } : {}),
|
|
390
420
|
},
|
|
391
421
|
});
|
|
392
422
|
return;
|
|
423
|
+
}
|
|
393
424
|
case 'tool-result':
|
|
394
425
|
rows.push({
|
|
395
426
|
...base,
|
|
396
427
|
toolUseId: typeof block.toolCallId === 'string' ? block.toolCallId : null,
|
|
397
|
-
content: { text: flattenToolResult(block.content), isError: block.isError === true },
|
|
428
|
+
content: { text: toolResultWithImages(flattenToolResult(block.content), message, sessionId, index), isError: block.isError === true },
|
|
398
429
|
});
|
|
399
430
|
return;
|
|
400
431
|
default:
|
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
|
|