@ran-sh/dsh-crew 1.5.0 → 1.6.0

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.
@@ -0,0 +1,110 @@
1
+ // Release liveness claims.
2
+ //
3
+ // A running Hub keeps executing the release it was started from, but it loads
4
+ // several modules lazily with a cache-busting query (`import('...?t=' + Date.now())`)
5
+ // so that a config edit is visible without a restart. Those lazy reads go to
6
+ // disk every time, which means deleting the release under a live process breaks
7
+ // it permanently: the routes that were already loaded keep working while
8
+ // everything behind a lazy import answers 500.
9
+ //
10
+ // Release retention keeps only the newest few, and it protected the *current*
11
+ // pointer — not the one a running process is actually executing. So a machine
12
+ // could be left with a Hub whose config routes were dead, and an update would
13
+ // not repair it because the damage is to the process, not the files.
14
+ //
15
+ // A process therefore records that it is using a release. Retention skips any
16
+ // release with a live claim. Liveness is decided by the pid, not by the file, so
17
+ // a process that dies without cleaning up cannot pin a release forever.
18
+
19
+ import { existsSync, mkdirSync, readFileSync, readdirSync, rmSync, writeFileSync } from 'node:fs';
20
+ import { homedir } from 'node:os';
21
+ import { dirname, join, resolve } from 'node:path';
22
+ import { fileURLToPath } from 'node:url';
23
+
24
+ export const IN_USE_DIRNAME = 'in-use';
25
+
26
+ export function releaseInUseDir({ home = homedir() } = {}) {
27
+ return join(home, '.config', 'dsh-crew', 'app', IN_USE_DIRNAME);
28
+ }
29
+
30
+ /** The release root that owns `moduleUrl`, or null when it is outside one. */
31
+ export function releaseRootFor(moduleUrl) {
32
+ let path;
33
+ try { path = fileURLToPath(moduleUrl); } catch { return null; }
34
+ // <release>/src/<...> or <release>/src/hub/entry.mjs → walk up to the release root.
35
+ let dir = dirname(path);
36
+ for (let depth = 0; depth < 6; depth++) {
37
+ if (existsSync(join(dir, 'package.json'))) {
38
+ try {
39
+ const pkg = JSON.parse(readFileSync(join(dir, 'package.json'), 'utf8'));
40
+ if (pkg?.name === '@ran-sh/dsh-crew') return resolve(dir);
41
+ } catch { /* not the payload manifest; keep walking */ }
42
+ }
43
+ const parent = dirname(dir);
44
+ if (parent === dir) break;
45
+ dir = parent;
46
+ }
47
+ return null;
48
+ }
49
+
50
+ function isAlive(pid) {
51
+ if (!Number.isInteger(pid) || pid <= 0) return false;
52
+ try { process.kill(pid, 0); return true; }
53
+ catch (error) { return error?.code === 'EPERM'; }
54
+ }
55
+
56
+ /**
57
+ * Record that this process is running `releasePath`. Best effort: failing to
58
+ * claim must never stop the Hub from starting, because the cost of a missed
59
+ * claim is a possible retention of one extra release.
60
+ */
61
+ export function claimReleaseInUse({ moduleUrl = import.meta.url, releasePath, home = homedir(), pid = process.pid, now = Date.now() } = {}) {
62
+ try {
63
+ const target = releasePath ?? releaseRootFor(moduleUrl);
64
+ if (!target) return null;
65
+ const dir = releaseInUseDir({ home });
66
+ mkdirSync(dir, { recursive: true });
67
+ const file = join(dir, `${pid}.json`);
68
+ writeFileSync(file, JSON.stringify({ pid, release: resolve(target), claimed_at: now }) + '\n');
69
+ return file;
70
+ } catch { return null; }
71
+ }
72
+
73
+ export function releaseClaimFile({ home = homedir(), pid = process.pid } = {}) {
74
+ return join(releaseInUseDir({ home }), `${pid}.json`);
75
+ }
76
+
77
+ export function releaseClaimInUse({ home = homedir(), pid = process.pid } = {}) {
78
+ const file = releaseClaimFile({ home, pid });
79
+ return existsSync(file) || false;
80
+ }
81
+
82
+ export function clearReleaseClaim({ home = homedir(), pid = process.pid } = {}) {
83
+ try { rmSync(releaseClaimFile({ home, pid }), { force: true }); return true; } catch { return false; }
84
+ }
85
+
86
+ /**
87
+ * Releases currently held by a live process. Dead claims are removed as they are
88
+ * encountered, so the directory cannot accumulate stale files.
89
+ */
90
+ export function liveReleaseClaims({ home = homedir(), alive = isAlive } = {}) {
91
+ const dir = releaseInUseDir({ home });
92
+ let names;
93
+ // A state directory that is unreadable — or replaced by a file — must read as
94
+ // "no claims" rather than throwing: this runs inside install, and failing it
95
+ // would block an update over a problem that only affects pruning.
96
+ try { names = readdirSync(dir); } catch { return []; }
97
+ const live = [];
98
+ for (const name of names) {
99
+ if (!name.endsWith('.json')) continue;
100
+ const file = join(dir, name);
101
+ let record;
102
+ try { record = JSON.parse(readFileSync(file, 'utf8')); } catch { record = null; }
103
+ if (record && Number.isInteger(record.pid) && typeof record.release === 'string' && alive(record.pid)) {
104
+ live.push(resolve(record.release));
105
+ continue;
106
+ }
107
+ try { rmSync(file, { force: true }); } catch { /* best effort */ }
108
+ }
109
+ return [...new Set(live)];
110
+ }
@@ -41,9 +41,6 @@ export const RUNTIME_SETTING_ACTIVATION = Object.freeze({
41
41
  pro_model_fallback: Object.freeze({ global: N, session: null }),
42
42
  adaptive_routing: Object.freeze({ global: N, session: null, note: 'Opt-in provider health ordering is read for the next Hub model selection; existing explicit priorities remain authoritative.' }),
43
43
  isolation: Object.freeze({ global: N, session: null }),
44
- vision_provider: Object.freeze({ global: N, session: null }),
45
- vision_model: Object.freeze({ global: N, session: null }),
46
- imagegen_provider: Object.freeze({ global: N, session: null }),
47
44
 
48
45
  // These values are copied into the MCP session defaults at process startup.
49
46
  // dsh_worker_config can override them for subsequent workflows in that same
@@ -56,11 +53,8 @@ export const RUNTIME_SETTING_ACTIVATION = Object.freeze({
56
53
  preset_flash: Object.freeze({ global: S, session: N }),
57
54
  preset_pro: Object.freeze({ global: S, session: N }),
58
55
 
59
- // hub-client freezes its base URL at module initialization. Crew Vision and
60
- // Image Generation tool registration is also decided at Hub plugin boot.
56
+ // hub-client freezes its base URL at module initialization.
61
57
  hub_url: Object.freeze({ global: R, session: null }),
62
- vision_enabled: Object.freeze({ global: R, session: null }),
63
- imagegen_enabled: Object.freeze({ global: R, session: null }),
64
58
  });
65
59
 
66
60
  export function activationForSetting(key) {
@@ -36,7 +36,7 @@ export {
36
36
  // included in the identity contract.
37
37
  const RUNTIME_ID = randomUUID();
38
38
 
39
- export const RUNTIME_VERSION = '1.5.0';
39
+ export const RUNTIME_VERSION = '1.6.0';
40
40
  export const HUB_PROTOCOL_VERSION = 1;
41
41
 
42
42
  export const HUB_CAPABILITIES = Object.freeze([
package/src/server.mjs CHANGED
@@ -305,10 +305,9 @@ const SAFE_GLOBAL_KEYS = [
305
305
  'default_tier', 'default_effort', 'mode', 'default_timeout_seconds', 'hub_url',
306
306
  'tier_policy', 'escalate_on_failure', 'subagents_enabled', 'collaboration_mode',
307
307
  'main_agent_mode', 'flash_state', 'pro_state', 'flash_roles', 'pro_roles',
308
- 'pro_reviews_flash', 'worker_provider_mode', 'vision_enabled', 'imagegen_enabled',
308
+ 'pro_reviews_flash', 'worker_provider_mode',
309
309
  'flash_model_priority', 'flash_model_priority_configured', 'flash_model_fallback',
310
310
  'pro_model_priority', 'pro_model_priority_configured', 'pro_model_fallback',
311
- 'vision_provider', 'vision_model', 'imagegen_provider',
312
311
  'preset_flash', 'preset_pro',
313
312
  ];
314
313
 
@@ -1,71 +0,0 @@
1
- import { mkdtempSync, mkdirSync, lstatSync, openSync, readSync, closeSync, renameSync, rmSync } from 'node:fs';
2
- import { basename, dirname, isAbsolute, join } from 'node:path';
3
-
4
- export async function downloadImageBytes(url, { fetchImpl = globalThis.fetch, timeoutMs = 60_000, maxBytes = 64 * 1024 * 1024 } = {}) {
5
- if (!['http:', 'https:'].includes(new URL(url).protocol)) throw new Error('unsupported image URL protocol');
6
- const controller = new AbortController();
7
- let reader;
8
- let timer;
9
- const timeout = new Promise((_, reject) => {
10
- timer = setTimeout(() => {
11
- controller.abort();
12
- reject(new Error('image download timed out'));
13
- }, timeoutMs);
14
- });
15
- const download = async () => {
16
- const response = await fetchImpl(url, { signal: controller.signal });
17
- controller.signal.throwIfAborted();
18
- if (!response.ok) throw new Error(`image download failed: HTTP ${response.status}`);
19
- if (Number(response.headers.get('content-length')) > maxBytes) throw new Error('image download exceeds size limit');
20
- if (!response.body) throw new Error('image download has no body');
21
- reader = response.body.getReader();
22
- const chunks = [];
23
- let bytes = 0;
24
- while (true) {
25
- const { done, value } = await reader.read();
26
- controller.signal.throwIfAborted();
27
- if (done) break;
28
- bytes += value.byteLength;
29
- if (bytes > maxBytes) throw new Error('image download exceeds size limit');
30
- chunks.push(Buffer.from(value));
31
- }
32
- return Buffer.concat(chunks, bytes);
33
- };
34
- try { return await Promise.race([download(), timeout]); }
35
- finally {
36
- clearTimeout(timer);
37
- controller.abort();
38
- if (reader) void reader.cancel().catch(() => {});
39
- }
40
- }
41
-
42
- function validateImage(file) {
43
- const info = lstatSync(file);
44
- if (!info.isFile() || info.size === 0 || info.size > 64 * 1024 * 1024) throw new Error('generated image has invalid type or size');
45
- const head = Buffer.alloc(32);
46
- const fd = openSync(file, 'r');
47
- let length;
48
- try { length = readSync(fd, head, 0, head.length, 0); } finally { closeSync(fd); }
49
- const bytes = head.subarray(0, length);
50
- const png = bytes.subarray(0, 8).equals(Buffer.from([137, 80, 78, 71, 13, 10, 26, 10]));
51
- const jpeg = bytes.length >= 3 && bytes[0] === 255 && bytes[1] === 216 && bytes[2] === 255;
52
- const gif = ['GIF87a', 'GIF89a'].includes(bytes.subarray(0, 6).toString('ascii'));
53
- const webp = bytes.subarray(0, 4).toString('ascii') === 'RIFF' && bytes.subarray(8, 12).toString('ascii') === 'WEBP';
54
- if (!png && !jpeg && !gif && !webp) throw new Error('generated output does not have a supported image signature');
55
- }
56
-
57
- /** Stage each request separately; an existing destination never proves success. */
58
- export async function withGeneratedImageOutput(outputPath, generate) {
59
- if (!isAbsolute(outputPath)) throw new Error('output_path must be absolute');
60
- mkdirSync(dirname(outputPath), { recursive: true });
61
- const stage = mkdtempSync(join(dirname(outputPath), '.dsh-image-'));
62
- const stagedFile = join(stage, basename(outputPath));
63
- try {
64
- await generate(stagedFile);
65
- validateImage(stagedFile);
66
- renameSync(stagedFile, outputPath);
67
- return outputPath;
68
- } finally {
69
- rmSync(stage, { recursive: true, force: true });
70
- }
71
- }
Binary file
@@ -1,138 +0,0 @@
1
- // Vision route: lets users paste images into DSH conversations on the
2
- // text-only DeepSeek models.
3
- //
4
- // Two cooperating pieces:
5
- // 1. A duck-typed LLM adapter registered as provider "deepseek-vision" that
6
- // advertises image input modality (passing the api-proxy admission gate)
7
- // and delegates every stream straight back to "deepseek-official".
8
- // registerAdapter does no instanceof check, so a plain object keeps the
9
- // zero-@deepseek-imports realm discipline.
10
- // 2. A global agent/pre-step waterfall that appends a text transcription (via
11
- // the multimodal describeFile pipeline) after each ImageBlock. The image
12
- // block itself stays in history so the conversation still shows the picture;
13
- // the adapter above drops image blocks on the way to the model, which is
14
- // what keeps the DeepSeek serializer (it throws on any image block) happy.
15
-
16
- import { writeFileSync, mkdirSync, existsSync } from 'node:fs';
17
- import { join } from 'node:path';
18
- import { homedir } from 'node:os';
19
- import { createHash } from 'node:crypto';
20
- import { describeFile } from './multimodal.mjs';
21
- import { tr } from './i18n.mjs';
22
-
23
- const VISION_PROVIDER = 'deepseek-vision';
24
- const DELEGATE = 'deepseek-official';
25
- const ATTACH_DIR = join(homedir(), '.config', 'dsh-crew', 'attachments');
26
-
27
- const EXT = { 'image/png': 'png', 'image/jpeg': 'jpg', 'image/webp': 'webp', 'image/gif': 'gif' };
28
-
29
- const TRANSCRIPT_PREFIX = (name) => tr(`[图片「${name}」已由视觉桥转写]`, `[Image "${name}" transcribed by the vision bridge]`);
30
- // Both locales' prefixes must be recognised: the user may switch language after
31
- // a transcription was written, and re-transcribing would duplicate it.
32
- const TRANSCRIPT_MARKERS = ['[图片「', '[Image "'];
33
-
34
- /** True when `block` is a transcription this route wrote for `imageBlock`. */
35
- function isTranscriptFor(block, imageBlock) {
36
- if (block?.type !== 'text' || typeof block.text !== 'string') return false;
37
- const name = imageBlock?.attachment?.name;
38
- if (name && (block.text.startsWith(`[图片「${name}」`) || block.text.startsWith(`[Image "${name}"`))) return true;
39
- return TRANSCRIPT_MARKERS.some((m) => block.text.startsWith(m));
40
- }
41
-
42
- /**
43
- * Images live on in history for the UI, but never reach the model: the
44
- * transcription that follows each one already carries the content, so the image
45
- * block is dropped rather than replaced with a placeholder.
46
- */
47
- function stripImages(content) {
48
- if (!Array.isArray(content)) return content;
49
- if (!content.some((b) => b?.type === 'image')) return content;
50
- const out = content.filter((b) => b?.type !== 'image');
51
- return out.length > 0 ? out : [{ type: 'text', text: tr('[图片已由视觉桥转写,详见随后的描述]', '[image transcribed by the vision bridge — see the description that follows]') }];
52
- }
53
-
54
- export function installVisionRoute(ctx, getConfig) {
55
- const llm = ctx.llm;
56
- const disposers = [];
57
-
58
- const adapter = {
59
- providerInfo: (p) => ({ id: p, name: tr('DeepSeek (视觉)', 'DeepSeek (Vision)') }),
60
- providerRetryPolicy: () => undefined,
61
- async listModels(p) {
62
- const models = await llm.listModels(DELEGATE);
63
- return models.map((m) => ({ ...m, provider: p, name: `${m.name} ◉`, inputModalities: ['text', 'image'] }));
64
- },
65
- async resolveModel(p, model, signal) {
66
- const base = await llm.resolveModelInfo(DELEGATE, model, signal);
67
- return { ...base, provider: p, name: `${base.name} ◉`, inputModalities: ['text', 'image'] };
68
- },
69
- async *stream(options) {
70
- // Safety net: any image block that survived pre-step would make the
71
- // DeepSeek serializer throw — strip defensively.
72
- // NOTE: routing reads top-level options.provider (adapterStream uses
73
- // this.registration(options.provider)); rewriting anything else
74
- // self-recurses into this adapter.
75
- const messages = options.messages?.map((m) => ({ ...m, content: stripImages(m.content) })) ?? options.messages;
76
- yield* llm.stream({ ...options, provider: DELEGATE, ...(messages === undefined ? {} : { messages }) });
77
- },
78
- };
79
- const handle = llm.registerAdapter([VISION_PROVIDER], adapter);
80
- disposers.push(() => handle());
81
-
82
- // Transcribe pending images before the step enters history. Applies to every
83
- // agent in this host: on a DS-only deployment any surviving image block is a
84
- // hard error downstream, so rewriting is strictly better than crashing.
85
- disposers.push(ctx.on('agent/pre-step', async (payload, next) => {
86
- const decision = await next();
87
- if (decision.kind !== 'enter') return decision;
88
- if (getConfig().vision_provider === 'off') return decision;
89
- let changed = false;
90
- const rewritten = [];
91
- for (const message of decision.messages) {
92
- if (!Array.isArray(message.content) || !message.content.some((b) => b?.type === 'image')) {
93
- rewritten.push(message);
94
- continue;
95
- }
96
- const content = [];
97
- for (const [i, block] of message.content.entries()) {
98
- if (block?.type !== 'image') { content.push(block); continue; }
99
- content.push(block); // keep the image so the conversation still shows it
100
- // Steps replay the whole history: skip anything already transcribed,
101
- // otherwise every step would append another copy.
102
- if (isTranscriptFor(message.content[i + 1], block)) continue;
103
- changed = true;
104
- try {
105
- const stored = await ctx.attachments.readImage(block.attachment, payload.signal);
106
- const sha = createHash('sha256').update(stored.data).digest('hex').slice(0, 12);
107
- const ext = EXT[block.attachment?.mediaType] ?? 'png';
108
- mkdirSync(ATTACH_DIR, { recursive: true });
109
- const path = join(ATTACH_DIR, `${sha}.${ext}`);
110
- if (!existsSync(path)) writeFileSync(path, stored.data);
111
- const name = block.attachment?.name ?? `${sha}.${ext}`;
112
- const desc = await describeFile(getConfig, path, tr(
113
- '详细描述这张图片:整体内容、布局结构、可见文字(逐字)、颜色与显著元素。',
114
- 'Describe this image in detail: overall content, layout, any visible text (verbatim), colours and notable elements.'));
115
- content.push({
116
- type: 'text',
117
- text: `${TRANSCRIPT_PREFIX(name)}\n${desc}\n${tr(
118
- `[原图: ${path} — 需要更多细节可用 describe_image 工具带具体问题查看]`,
119
- `[Original: ${path} — for more detail, ask the describe_image tool a specific question]`)}`,
120
- });
121
- } catch (err) {
122
- // Same prefix as a successful transcription so the dedup check above
123
- // treats it as handled — otherwise a broken provider would append a
124
- // fresh failure notice on every step.
125
- content.push({
126
- type: 'text',
127
- text: `${TRANSCRIPT_PREFIX(block.attachment?.name ?? tr('图片', 'image'))}\n${tr(`[转写失败: ${err?.message ?? err}]`, `[transcription failed: ${err?.message ?? err}]`)}`,
128
- });
129
- }
130
- }
131
- rewritten.push({ ...message, content });
132
- }
133
- return changed ? { kind: 'enter', messages: rewritten } : decision;
134
- }));
135
-
136
- ctx.logger?.info?.('dsh-crew: vision route registered (provider "deepseek-vision" + pre-step transcription)');
137
- return () => { for (const d of disposers.reverse()) { try { d(); } catch {} } };
138
- }