@hzxwonder/dsh-mcp 0.3.0 → 0.5.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.
package/src/provider.js CHANGED
@@ -1,11 +1,31 @@
1
- export const DEFAULT_BASE_URL = 'https://api.deepseek.com/anthropic';
1
+ import { stripVTControlCharacters } from 'node:util';
2
+ import { fileURLToPath } from 'node:url';
3
+ import { createModels, createProvider } from '@earendil-works/pi-ai/models';
4
+ import { getBuiltinModels, getBuiltinProviders } from '@earendil-works/pi-ai/providers/all';
5
+ import { openAICompletionsApi } from '@earendil-works/pi-ai/api/openai-completions.lazy';
6
+ import { openAIResponsesApi } from '@earendil-works/pi-ai/api/openai-responses.lazy';
7
+ import { anthropicMessagesApi } from '@earendil-works/pi-ai/api/anthropic-messages.lazy';
2
8
 
3
- export function normalizeBaseUrl(value = DEFAULT_BASE_URL) {
4
- if (typeof value !== 'string' || /[\r\n\0]/.test(value)) {
5
- throw new Error('Base URL must be one HTTP or HTTPS URL without line breaks.');
9
+ export const PROTOCOLS = Object.freeze([
10
+ { id: 'chat-completions', label: 'OpenAI Chat Completions — POST /chat/completions', defaultBaseUrl: 'https://api.deepseek.com/v1', api: 'openai-completions' },
11
+ { id: 'responses', label: 'OpenAI Responses (Codex) — POST /responses', defaultBaseUrl: 'https://api.openai.com/v1', api: 'openai-responses' },
12
+ { id: 'anthropic', label: 'Anthropic Messages — POST /v1/messages', defaultBaseUrl: 'https://api.anthropic.com', api: 'anthropic-messages' },
13
+ ]);
14
+ export const DEFAULT_PROTOCOL = 'chat-completions';
15
+ export const DEFAULT_BASE_URL = PROTOCOLS[0].defaultBaseUrl;
16
+ const LEGACY_BASE_URL = 'https://api.deepseek.com/anthropic';
17
+ const EFFORTS = ['off', 'low', 'high', 'max'];
18
+ const CONTROLS = /[\p{Cc}\p{Cf}\p{Zl}\p{Zp}]/u;
19
+ const preferredProviders = ['deepseek', 'openai', 'anthropic'];
20
+ const catalog = [...preferredProviders, ...getBuiltinProviders().filter((provider) => !preferredProviders.includes(provider))]
21
+ .flatMap((provider) => getBuiltinModels(provider));
22
+
23
+ export function normalizeBaseUrl(value = DEFAULT_BASE_URL, fallback = DEFAULT_BASE_URL) {
24
+ if (typeof value !== 'string' || CONTROLS.test(value)) {
25
+ throw new Error('Base URL must be one HTTP or HTTPS URL without control characters.');
6
26
  }
7
27
  let url;
8
- try { url = new URL(value.trim() || DEFAULT_BASE_URL); }
28
+ try { url = new URL(value.trim() || fallback); }
9
29
  catch { throw new Error('Base URL must be an absolute HTTP or HTTPS URL.'); }
10
30
  if (!['http:', 'https:'].includes(url.protocol) || url.username || url.password || url.search || url.hash) {
11
31
  throw new Error('Base URL must use HTTP or HTTPS without credentials, a query, or a fragment.');
@@ -23,3 +43,279 @@ export function parseProviderSettings(text) {
23
43
  }
24
44
  return settings;
25
45
  }
46
+
47
+ function modelId(value) {
48
+ if (typeof value !== 'string' || !value.trim() || value !== value.trim() || value.length > 512 || CONTROLS.test(value)) {
49
+ throw new Error('Model ID must be nonempty, at most 512 characters, and contain no control characters or surrounding whitespace.');
50
+ }
51
+ return value;
52
+ }
53
+
54
+ export function resolveProviderSettings(input = {}, { legacy = false } = {}) {
55
+ const protocol = input.protocol ?? (legacy ? 'anthropic' : DEFAULT_PROTOCOL);
56
+ const definition = PROTOCOLS.find((item) => item.id === protocol);
57
+ if (!definition) throw new Error('Protocol must be chat-completions, responses, or anthropic.');
58
+ const fallback = legacy && input.protocol == null ? LEGACY_BASE_URL : definition.defaultBaseUrl;
59
+ return {
60
+ protocol,
61
+ baseUrl: normalizeBaseUrl(input.baseUrl ?? fallback, fallback),
62
+ model: modelId(input.model ?? 'deepseek-flash'),
63
+ };
64
+ }
65
+
66
+ function requireApiKey(apiKey) {
67
+ if (typeof apiKey !== 'string' || !apiKey.trim() || CONTROLS.test(apiKey)) {
68
+ throw new Error('A nonempty API key without control characters is required.');
69
+ }
70
+ }
71
+
72
+ function boundedSignal(signal, milliseconds) {
73
+ const timeout = AbortSignal.timeout(milliseconds);
74
+ return signal ? AbortSignal.any([signal, timeout]) : timeout;
75
+ }
76
+
77
+ async function readModelPage(response) {
78
+ if (response.status >= 300 && response.status < 400) {
79
+ await response.body?.cancel();
80
+ throw new Error('Model discovery refused a redirect; use the provider’s final Base URL.');
81
+ }
82
+ if (!response.ok) {
83
+ await response.body?.cancel();
84
+ const detail = [401, 403].includes(response.status) ? ' Credentials were rejected or lack model-list permission.' : '';
85
+ throw new Error(`Model discovery failed (HTTP ${response.status}).${detail}`);
86
+ }
87
+ const chunks = [];
88
+ let bytes = 0;
89
+ for await (const chunk of response.body ?? []) {
90
+ bytes += chunk.length;
91
+ if (bytes > 2 * 1024 * 1024) throw new Error('Model discovery returned an oversized response.');
92
+ chunks.push(chunk);
93
+ }
94
+ let page;
95
+ try { page = JSON.parse(Buffer.concat(chunks).toString('utf8')); }
96
+ catch { throw new Error('Model discovery did not return a valid JSON model list.'); }
97
+ if (!page || !Array.isArray(page.data)) throw new Error('Model discovery did not return a data array.');
98
+ return page;
99
+ }
100
+
101
+ export async function discoverModels({ protocol, baseUrl, apiKey, signal }) {
102
+ const settings = resolveProviderSettings({ protocol, baseUrl });
103
+ requireApiKey(apiKey);
104
+ const url = new URL(settings.baseUrl);
105
+ const anthropic = settings.protocol === 'anthropic';
106
+ // The official DSH Messages adapter lists DeepSeek models at /models on this same origin.
107
+ const deepseek = anthropic && url.origin === 'https://api.deepseek.com';
108
+ url.pathname = deepseek ? '/models' : `${url.pathname.replace(/\/+$/, '')}${anthropic && !url.pathname.endsWith('/v1') ? '/v1' : ''}/models`;
109
+ const headers = anthropic ? { 'x-api-key': apiKey, 'anthropic-version': '2023-06-01' } : { Authorization: `Bearer ${apiKey}` };
110
+ const requestSignal = boundedSignal(signal, 15000);
111
+ const models = new Map();
112
+ const cursors = new Set();
113
+ try {
114
+ for (let count = 0; count < 100; count++) {
115
+ const page = await readModelPage(await fetch(url, { headers, signal: requestSignal, redirect: 'manual' }));
116
+ if (anthropic && !deepseek && page.has_more !== undefined && typeof page.has_more !== 'boolean') {
117
+ throw new Error('Model discovery returned invalid pagination.');
118
+ }
119
+ for (const entry of page.data) {
120
+ if (!entry || typeof entry !== 'object') throw new Error('Model discovery returned an invalid model entry.');
121
+ const id = modelId(entry.id);
122
+ const name = typeof (entry.display_name ?? entry.name) === 'string'
123
+ ? stripVTControlCharacters(entry.display_name ?? entry.name).replace(/[\p{Cc}\p{Cf}\p{Zl}\p{Zp}]/gu, '').slice(0, 256).trim() : '';
124
+ models.set(id, name ? { id, name } : { id });
125
+ }
126
+ if (!anthropic || deepseek || page.has_more !== true) return [...models.values()];
127
+ const cursor = modelId(page.last_id);
128
+ if (!page.data.length || cursors.has(cursor)) throw new Error('Model discovery returned invalid pagination.');
129
+ cursors.add(cursor);
130
+ url.searchParams.set('after_id', cursor);
131
+ }
132
+ throw new Error('Model discovery exceeded its pagination limit.');
133
+ } catch (error) {
134
+ if (requestSignal.aborted) throw new Error(signal?.aborted ? 'Model discovery cancelled.' : 'Model discovery timed out.');
135
+ // Never relay provider bodies, fetch causes, URLs, or credential-bearing headers.
136
+ if (/^(Model discovery|Model ID)/.test(error.message)) throw error;
137
+ throw new Error('Model discovery could not reach the selected endpoint. Check the Base URL and network.');
138
+ }
139
+ }
140
+
141
+ function describeRuntime(settings) {
142
+ const definition = PROTOCOLS.find((item) => item.id === settings.protocol);
143
+ const deepseekModel = catalog.find((item) => item.provider === 'deepseek' && item.id === settings.model);
144
+ const direct = settings.protocol === 'anthropic' && deepseekModel !== undefined;
145
+ const known = direct ? deepseekModel : catalog.find((item) => item.id === settings.model && item.api === definition.api);
146
+ const thinkingEfforts = known?.reasoning ? EFFORTS.filter((effort) => {
147
+ const wire = known.thinkingLevelMap?.[effort];
148
+ return wire !== null && (effort !== 'max' || wire !== undefined);
149
+ }) : ['off'];
150
+ if (!thinkingEfforts.length) throw new Error('The selected model has no supported off, low, high, or max effort in the installed metadata.');
151
+ const route = direct ? 'deepseek-official' : known?.provider ?? 'dsh-mcp';
152
+ const reasoningEfforts = known?.reasoning ? Object.fromEntries(thinkingEfforts.map((effort) => [effort, known.thinkingLevelMap?.[effort] ?? (effort === 'off' ? null : effort)])) : false;
153
+ const model = {
154
+ id: settings.model,
155
+ ...(known ? { name: known.name, contextWindow: known.contextWindow, input: known.input } : {}),
156
+ reasoningEfforts,
157
+ };
158
+ const compat = !known && settings.protocol === 'chat-completions'
159
+ ? { supportsStore: false, supportsDeveloperRole: false, maxTokensField: 'max_tokens', supportsStrictMode: false } : undefined;
160
+ const profile = {
161
+ apiKeyEnv: 'DEEPSEEK_API_KEY', api: definition.api,
162
+ baseURL: settings.protocol === 'anthropic' ? settings.baseUrl.replace(/\/v1$/, '') : settings.baseUrl,
163
+ models: [model], defaultContextWindow: 32768, defaultMaxTokens: 4096,
164
+ ...(compat ? { compat } : {}),
165
+ };
166
+ const directProfile = direct ? {
167
+ apiKeyEnv: 'DEEPSEEK_API_KEY', baseURL: settings.baseUrl,
168
+ models: [{ id: settings.model, name: known.name, contextWindow: known.contextWindow, inputModalities: known.input }],
169
+ } : {};
170
+ return { route, known, profile, directProfile, thinkingEfforts };
171
+ }
172
+
173
+ export function buildProviderRuntime(input, apiKey) {
174
+ const settings = resolveProviderSettings(input);
175
+ if (apiKey !== undefined) requireApiKey(apiKey);
176
+ const { route, known, profile, directProfile, thinkingEfforts } = describeRuntime(settings);
177
+ const contextWindow = known?.contextWindow ?? profile.defaultContextWindow;
178
+ const headroomTokens = Math.max(1, Math.floor(contextWindow / 8));
179
+ const compaction = contextWindow < 131072 ? { headroomTokens, maxTokens: Math.min(4096, headroomTokens) } : {};
180
+ return {
181
+ protocol: settings.protocol, model: settings.model, route, thinkingEfforts,
182
+ env: {
183
+ ...(apiKey === undefined ? {} : { DEEPSEEK_API_KEY: apiKey }),
184
+ DEEPSEEK_BASE_URL: settings.baseUrl,
185
+ DSH_MCP_PROTOCOL: settings.protocol, DSH_MCP_MODEL: settings.model, DSH_MCP_PROVIDER: route,
186
+ DSH_MCP_PI_PLUGIN: !known?.reasoning && route !== 'deepseek-official' ? fileURLToPath(import.meta.url) : '@deepseek-ai/dsh-llm-pi-ai',
187
+ DSH_MCP_PI_PROVIDERS: JSON.stringify(route === 'deepseek-official' ? {} : { [route]: profile }),
188
+ DSH_MCP_DEEPSEEK_PROFILE: JSON.stringify(directProfile),
189
+ DSH_MCP_COMPACTION_PROFILE: JSON.stringify(compaction),
190
+ },
191
+ };
192
+ }
193
+
194
+ function nativeModel(settings, route, known, profile) {
195
+ return {
196
+ ...known, id: settings.model, name: known?.name ?? settings.model, api: profile.api,
197
+ provider: route, baseUrl: profile.baseURL, reasoning: known?.reasoning ?? false,
198
+ input: known?.input ?? ['text'], contextWindow: known?.contextWindow ?? profile.defaultContextWindow,
199
+ maxTokens: known?.maxTokens ?? profile.defaultMaxTokens,
200
+ cost: known?.cost ?? { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
201
+ compat: known?.compat ?? profile.compat,
202
+ };
203
+ }
204
+
205
+ function nativeApi(protocol) {
206
+ return { 'chat-completions': openAICompletionsApi, responses: openAIResponsesApi, anthropic: anthropicMessagesApi }[protocol]();
207
+ }
208
+
209
+ // Pinned DSH hides the native adapter's valid off level for non-reasoning models.
210
+ // Advertise only that level; the unchanged native stream still receives explicit off.
211
+ export const name = 'dsh-mcp-off-only';
212
+ export const inject = ['llm'];
213
+ export async function apply(ctx) {
214
+ const [{ PiAiAdapter }, { InMemoryCredentialStore, defaultProviderAuthContext }, { resolveRetryPolicy, resolveImageAttachmentAccess }] = await Promise.all([
215
+ import('@deepseek-ai/dsh-llm-pi-ai'),
216
+ import('@earendil-works/pi-ai'),
217
+ import('@deepseek-ai/dsh-llm'),
218
+ ]);
219
+ const settings = resolveProviderSettings({
220
+ protocol: process.env.DSH_MCP_PROTOCOL, baseUrl: process.env.DEEPSEEK_BASE_URL, model: process.env.DSH_MCP_MODEL,
221
+ });
222
+ const { route, known, profile, thinkingEfforts } = describeRuntime(settings);
223
+ if (thinkingEfforts.length !== 1 || thinkingEfforts[0] !== 'off' || known?.reasoning) {
224
+ throw new Error('The off-only adapter requires a model without selectable reasoning metadata.');
225
+ }
226
+ const descriptor = nativeModel(settings, route, known, profile);
227
+ const resolveApiKey = async () => {
228
+ const credentials = ctx.get('credentials');
229
+ const apiKey = credentials ? (await credentials.resolve(profile.apiKeyEnv))?.value : process.env[profile.apiKeyEnv];
230
+ requireApiKey(apiKey);
231
+ return apiKey;
232
+ };
233
+ const piProvider = createProvider({
234
+ id: route, baseUrl: profile.baseURL, models: [descriptor], api: nativeApi(settings.protocol),
235
+ auth: { apiKey: { name: 'Configured API key', resolve: async () => ({ auth: { apiKey: await resolveApiKey() } }) } },
236
+ });
237
+ const resolved = {
238
+ ...profile, provider: route, displayName: route, piProvider,
239
+ streamIdleTimeoutMs: 300000, maxRequestImageBytes: 20 * 1024 * 1024, maxRequestImages: 50,
240
+ imageOffloadCountQuantum: 10, requestImagePixelBudget: 4194304, requestImageMaxBytes: 4 * 1024 * 1024,
241
+ retryPolicy: resolveRetryPolicy(undefined, 'dsh-mcp'),
242
+ modelErrors: new Map(), configuredMaxTokens: new Map(),
243
+ };
244
+ const profiles = new Map([[route, resolved]]);
245
+ const withOff = (model) => ({
246
+ ...model, reasoning: { efforts: [{ id: 'off', name: 'Off', description: 'No explicit reasoning request; unknown providers retain their own defaults.' }] },
247
+ });
248
+ class OffOnlyAdapter extends PiAiAdapter {
249
+ async resolveModel(...args) { return withOff(await super.resolveModel(...args)); }
250
+ async prepareCall(...args) {
251
+ const prepared = await super.prepareCall(...args);
252
+ return { ...prepared, model: withOff(prepared.model) };
253
+ }
254
+ }
255
+ ctx.llm.registerAdapter([route], new OffOnlyAdapter({
256
+ profiles: () => profiles, resolveApiKey,
257
+ auth: { credentials: new InMemoryCredentialStore(), authContext: defaultProviderAuthContext() },
258
+ prepareHeaders: (request, headers) => ctx.waterfall('llm-pi-ai/request-headers', request, headers, () => headers),
259
+ resolveAttachments: () => ctx.get('attachments'),
260
+ resolveImageAccess: (attachments, ref) => resolveImageAttachmentAccess(attachments, (path) => ctx.get('fs')?.processPathFromHostPath(path), ref),
261
+ }));
262
+ }
263
+
264
+ async function probeDirect(settings, apiKey, signal, directProfile) {
265
+ const { DeepSeekAdapter, resolveAdapterOptions } = await import('@deepseek-ai/dsh-llm-deepseek');
266
+ const { apiKeyEnv, ...configuration } = directProfile;
267
+ const connection = resolveAdapterOptions({ ...configuration, reasoningEffort: 'off', maxTokens: 32, streamIdleTimeoutMs: 20000, retryPolicy: { mode: 'normal', maxRetries: 0 } });
268
+ const adapter = new DeepSeekAdapter({
269
+ options: () => connection,
270
+ resolveAuth: async () => ({ headers: { 'x-api-key': apiKey } }),
271
+ resolveUserId: () => 'dsh-mcp-connection-check',
272
+ prepareExtensions: async () => ({ fields: {}, accept: async () => {} }),
273
+ });
274
+ let content = false;
275
+ let finished = false;
276
+ for await (const event of adapter.stream({
277
+ provider: 'deepseek-official', model: settings.model, reasoningEffort: 'off', maxTokens: 32, signal,
278
+ messages: [{ role: 'user', content: [{ type: 'text', text: 'Reply with OK.' }] }],
279
+ })) {
280
+ if (event.type === 'text-delta' && event.text?.trim()) content = true;
281
+ if (event.type === 'finish') finished = ['stop', 'max-tokens'].includes(event.reason.kind);
282
+ }
283
+ if (!content || !finished) throw new Error('Connection test did not receive a completed model response.');
284
+ }
285
+
286
+ export async function testConnection({ protocol, baseUrl, model, apiKey, signal }) {
287
+ const settings = resolveProviderSettings({ protocol, baseUrl, model });
288
+ requireApiKey(apiKey);
289
+ const requestSignal = boundedSignal(signal, 20000);
290
+ const { route, known, profile, directProfile, thinkingEfforts } = describeRuntime(settings);
291
+ try {
292
+ if (route === 'deepseek-official') {
293
+ await probeDirect(settings, apiKey, requestSignal, directProfile);
294
+ } else {
295
+ const descriptor = nativeModel(settings, route, known, profile);
296
+ const api = nativeApi(settings.protocol);
297
+ const models = createModels();
298
+ models.setProvider(createProvider({
299
+ id: route, baseUrl: profile.baseURL, models: [descriptor], api,
300
+ auth: { apiKey: { name: 'Explicit setup key', resolve: async () => ({ auth: { apiKey } }) } },
301
+ }));
302
+ const response = await models.completeSimple(descriptor, {
303
+ messages: [{ role: 'user', content: 'Reply with OK.', timestamp: Date.now() }],
304
+ }, {
305
+ apiKey, signal: requestSignal, reasoning: thinkingEfforts.includes('off') ? undefined : thinkingEfforts[0],
306
+ maxTokens: 32, maxRetries: 0, timeoutMs: 20000, transport: 'sse',
307
+ fetch: (url, options) => fetch(url, { ...options, redirect: 'error' }),
308
+ });
309
+ const hasOutput = response.content.some((part) =>
310
+ part.type === 'text' && part.text?.trim() || part.type === 'thinking' && part.thinking?.trim())
311
+ || response.usage?.output > 0;
312
+ if (!['stop', 'length'].includes(response.stopReason) || !hasOutput) {
313
+ throw new Error('Connection test did not receive a completed model response.');
314
+ }
315
+ }
316
+ return settings;
317
+ } catch {
318
+ if (requestSignal.aborted) throw new Error(signal?.aborted ? 'Connection test cancelled.' : 'Connection test timed out.');
319
+ throw new Error('Connection test failed: no valid inference response. Check protocol, Base URL, API key, model access, and billing.');
320
+ }
321
+ }
package/src/server.js CHANGED
@@ -7,7 +7,7 @@ import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js'
7
7
  import { z } from 'zod';
8
8
  import { createWorker } from './worker.js';
9
9
  import metadata from '../package.json' with { type: 'json' };
10
- import { normalizeBaseUrl, parseProviderSettings } from './provider.js';
10
+ import { buildProviderRuntime, resolveProviderSettings, parseProviderSettings } from './provider.js';
11
11
 
12
12
  const { values } = parseArgs({ options: {
13
13
  'dsh-home': { type: 'string', default: join(homedir(), '.dsh-mcp') },
@@ -25,19 +25,25 @@ catch (error) { if (error.code !== 'ENOENT') throw error; }
25
25
  let provider = {};
26
26
  try { provider = parseProviderSettings(await readFile(join(home, 'settings.json'), 'utf8')); }
27
27
  catch (error) { if (error.code !== 'ENOENT') throw error; }
28
- const apiKey = process.env.DEEPSEEK_API_KEY ?? stored.DEEPSEEK_API_KEY;
29
- if (apiKey !== undefined) process.env.DEEPSEEK_API_KEY = apiKey;
30
- process.env.DEEPSEEK_BASE_URL = normalizeBaseUrl(process.env.DEEPSEEK_BASE_URL ?? provider.baseUrl);
28
+ const apiKey = process.env.DEEPSEEK_API_KEY?.trim() || stored.DEEPSEEK_API_KEY?.trim() || undefined;
29
+ const savedProvider = resolveProviderSettings(provider, { legacy: !!provider.baseUrl && !provider.protocol });
30
+ const protocol = process.env.DSH_MCP_PROTOCOL || savedProvider.protocol;
31
+ const providerRuntime = buildProviderRuntime({
32
+ protocol,
33
+ baseUrl: process.env.DEEPSEEK_BASE_URL ?? (protocol === savedProvider.protocol ? savedProvider.baseUrl : undefined),
34
+ model: process.env.DSH_MCP_MODEL || savedProvider.model,
35
+ }, apiKey);
31
36
 
32
- const worker = createWorker({ home });
37
+ const worker = createWorker({ home, providerRuntime });
33
38
  const instructions = await readFile(new URL('../config/routing.md', import.meta.url), 'utf8');
34
39
  const server = new McpServer({ name: 'dsh-mcp', version: metadata.version }, { instructions });
35
40
  server.registerTool('delegate_to_deepseek', {
36
41
  title: 'Delegate a bounded task to DeepSeek Harness',
37
- description: 'Run one bounded task with DeepSeek Flash. Supply the absolute project_root from active Codex context or explicit user authorization. Use read-only for analysis. Select workspace-write explicitly for edits. Supply complete context and acceptance criteria. Pause other writers. Review the actual files and check the result. A completed status does not mean task acceptance.',
42
+ description: 'Run one bounded task with the configured model through local DeepSeek Harness. Supply the absolute project_root from active Codex context or explicit user authorization. Use read-only for analysis. Select workspace-write explicitly for edits. Supply complete context and acceptance criteria. Pause other writers. Review the actual files and check the result. A completed status does not mean task acceptance.',
38
43
  inputSchema: {
39
44
  project_root: z.string().min(1).describe('Required absolute project directory from Codex context or explicit user authorization. This is the caller directory grant, not an independently verified workspace ACL.'),
40
45
  task: z.string().trim().min(1).max(65536).describe('Self-contained task, allowed files, constraints, acceptance criteria and requested verification.'),
46
+ thinking_effort: z.enum(providerRuntime.thinkingEfforts).describe('Required model-supported DSH reasoning effort. Choose explicitly for every call and resume; unsupported levels are rejected, never silently clamped.'),
41
47
  cwd: z.string().min(1).default('.').describe('Directory relative to project_root, or an absolute directory within project_root.'),
42
48
  permission_mode: z.enum(['read-only', 'workspace-write']).default('read-only'),
43
49
  session_id: z.string().min(1).max(256).optional().describe('Previously returned DSH session ID for scoped revision in the same cwd.'),
@@ -0,0 +1,72 @@
1
+ import { realpath } from 'node:fs/promises';
2
+ import { isAbsolute, join } from 'node:path';
3
+ import { Context } from '@deepseek-ai/cordis';
4
+ import { Session } from '@deepseek-ai/dsh-session';
5
+ import * as JsonlSessionPersistence from '@deepseek-ai/dsh-session-persistence-jsonl';
6
+ import { currentSessionMessageProjections } from '@deepseek-ai/dsh-session-format-catalog/message-projections';
7
+
8
+ export async function readSessionOutput({ home, sessionId, cwd }) {
9
+ if (typeof sessionId !== 'string' || !sessionId) throw new Error('A persisted DSH session ID is required');
10
+ if (typeof cwd !== 'string' || !isAbsolute(cwd)) throw new Error('The session cwd must be an absolute path');
11
+ const physicalCwd = await realpath(cwd);
12
+ const ctx = new Context();
13
+ try {
14
+ await ctx.plugin(JsonlSessionPersistence, { root: join(home, 'sessions') }).await();
15
+ const persistence = ctx.sessionPersistence;
16
+ const snapshot = await persistence.stat(sessionId);
17
+ if (!snapshot?.location?.path) throw new Error('Persisted session log not found');
18
+ const handle = await persistence.open(sessionId, 'read');
19
+ let read;
20
+ try {
21
+ if (handle.header.id !== sessionId || snapshot.header.id !== sessionId) {
22
+ throw new Error('Persisted session header ID does not match the requested session');
23
+ }
24
+ if (!handle.header.cwd || await realpath(handle.header.cwd) !== physicalCwd
25
+ || snapshot.header.cwd !== handle.header.cwd) {
26
+ throw new Error('Persisted session cwd does not match the requested physical cwd');
27
+ }
28
+ read = await handle.read();
29
+ } finally {
30
+ await handle.close();
31
+ }
32
+
33
+ // Restore only in memory: the storage backend owns decoding and migrations.
34
+ const session = Session.fromRestore(sessionId, read.events, handle.header,
35
+ handle.inheritedEventCount, read.eventState, currentSessionMessageProjections);
36
+ const events = read.events;
37
+ let start = events.length - 1;
38
+ while (start >= 0 && events[start].type !== 'turn/start') start--;
39
+ if (start < 0) throw new Error('Persisted session has no durable turn');
40
+ const turn = events[start].data.turn;
41
+ const current = new Set(session.surface.nodes);
42
+ let text = '';
43
+ let assistantSeq = null;
44
+ let terminalReason = null;
45
+ for (let index = events.length - 1; index > start; index--) {
46
+ const event = events[index];
47
+ if (event.type === 'turn/end' && event.data.turn === turn && terminalReason === null) {
48
+ terminalReason = event.data.reason;
49
+ }
50
+ if (event.type !== 'assistant/message' || event.data.turn !== turn
51
+ || assistantSeq !== null || !current.has(event.seq)) continue;
52
+ const message = session.deriveEventMessage(event);
53
+ const visibleText = message?.content.filter((block) => block.type === 'text').map((block) => block.text).join('') ?? '';
54
+ if (visibleText) {
55
+ text = visibleText;
56
+ assistantSeq = event.seq;
57
+ }
58
+ }
59
+ return {
60
+ session_id: sessionId,
61
+ text,
62
+ session_file: snapshot.location.path,
63
+ assistant_seq: assistantSeq,
64
+ turn,
65
+ terminal_reason: terminalReason,
66
+ };
67
+ } catch (error) {
68
+ throw new Error(`Cannot read persisted DSH session "${sessionId}": ${error.message}`, { cause: error });
69
+ } finally {
70
+ await ctx.fiber.dispose();
71
+ }
72
+ }
@@ -0,0 +1,183 @@
1
+ import { createInterface } from 'node:readline';
2
+ import { Writable } from 'node:stream';
3
+ import { PROTOCOLS, DEFAULT_PROTOCOL, normalizeBaseUrl, resolveProviderSettings, discoverModels, testConnection } from './provider.js';
4
+
5
+ const clean = (value) => String(value).replace(/[\p{Cc}\p{Cf}\p{Zl}\p{Zp}]/gu, '?');
6
+
7
+ export function maskApiKey(value) {
8
+ if (!value) return '未设置';
9
+ if (/[\p{Cc}\p{Cf}\p{Zl}\p{Zp}]/u.test(value)) return '[REDACTED]';
10
+ const masked = value.length >= 12 ? `********${value.slice(-4)}` : '********';
11
+ return masked === value ? '[REDACTED]' : masked;
12
+ }
13
+
14
+ function question(prompt, hidden = false) {
15
+ if (!process.stdin.isTTY) throw new Error('配置向导需要交互式终端。请在终端运行 dsh-mcp setup。');
16
+ const output = hidden ? new Writable({ write(_chunk, _encoding, done) { done(); } }) : process.stdout;
17
+ const input = createInterface({ input: process.stdin, output, terminal: true, historySize: 0 });
18
+ if (hidden) process.stdout.write(prompt);
19
+ return new Promise((accept, reject) => {
20
+ input.once('SIGINT', () => { input.close(); reject(new Error('配置已取消,尚未保存。')); });
21
+ input.once('close', () => reject(new Error('输入已关闭,尚未保存。')));
22
+ input.question(hidden ? '' : prompt, (answer) => {
23
+ accept(answer);
24
+ input.close();
25
+ if (hidden) process.stdout.write('\n');
26
+ });
27
+ });
28
+ }
29
+
30
+ async function progress(label, operation) {
31
+ const frames = ['|', '/', '-', '\\'];
32
+ let frame = 0;
33
+ process.stdout.write(`${label} ${frames[frame]}`);
34
+ const timer = setInterval(() => process.stdout.write(`\r\u001b[2K${label} ${frames[++frame % frames.length]}`), 120);
35
+ try { return await operation(AbortSignal.timeout(45000)); }
36
+ finally { clearInterval(timer); process.stdout.write('\r\u001b[2K'); }
37
+ }
38
+
39
+ export async function runTerminalSetup({ settings, apiKey = '', existing = false, io = {} }) {
40
+ const ask = io.ask ?? question;
41
+ const log = io.log ?? console.log;
42
+ const run = io.progress ?? progress;
43
+ const draft = { ...settings };
44
+ let key = apiKey;
45
+ const errorText = (error) => {
46
+ const message = String(error?.message ?? error);
47
+ return clean(key ? message.split(key).join('[REDACTED]') : message);
48
+ };
49
+ const protocol = () => PROTOCOLS.find((item) => item.id === draft.protocol);
50
+ const summary = () => {
51
+ log('\n配置汇总(密钥不会明文显示)');
52
+ log(` 1. 接口类型: ${protocol()?.label ?? clean(draft.protocol)}`);
53
+ log(` 2. Base URL: ${clean(draft.baseUrl)}`);
54
+ log(` 3. API Key: ${maskApiKey(key)}`);
55
+ log(` 4. 模型: ${draft.model ? clean(draft.model) : '待选择'}`);
56
+ };
57
+ const chooseProtocol = async () => {
58
+ log('\n第一层:选择接口协议');
59
+ PROTOCOLS.forEach((item, index) => log(` ${index + 1}. ${item.label}`));
60
+ const current = Math.max(0, PROTOCOLS.findIndex((item) => item.id === (draft.protocol || DEFAULT_PROTOCOL)));
61
+ for (;;) {
62
+ const answer = (await ask(`接口 [${current + 1}]: `)).trim();
63
+ const index = answer ? Number(answer) - 1 : current;
64
+ if (Number.isInteger(index) && PROTOCOLS[index]) {
65
+ const selected = PROTOCOLS[index];
66
+ if (draft.protocol !== selected.id) {
67
+ draft.protocol = selected.id;
68
+ draft.baseUrl = selected.defaultBaseUrl;
69
+ draft.model = '';
70
+ }
71
+ return;
72
+ }
73
+ log('请输入列表中的编号。');
74
+ }
75
+ };
76
+ const chooseUrl = async () => {
77
+ log('\n第二层:输入 API 根地址,不是具体的推理端点。');
78
+ for (;;) {
79
+ const value = (await ask(`Base URL [${draft.baseUrl || protocol().defaultBaseUrl}]: `)).trim() || draft.baseUrl || protocol().defaultBaseUrl;
80
+ try {
81
+ const normalized = normalizeBaseUrl(value, protocol().defaultBaseUrl);
82
+ if (draft.baseUrl !== normalized) draft.model = '';
83
+ draft.baseUrl = normalized;
84
+ return;
85
+ } catch (error) { log(errorText(error)); }
86
+ }
87
+ };
88
+ const chooseKey = async () => {
89
+ log(`\n第三层:输入 API Key。目标服务: ${clean(draft.baseUrl)}`);
90
+ if (key) log('回车表示保留已保存的密钥,并授权将它用于上方服务;也可以输入新密钥。');
91
+ for (;;) {
92
+ const value = (await ask(`API Key${key ? ` [${maskApiKey(key)},回车保留]` : ''}: `, true)).trim();
93
+ if (value && /[\p{Cc}\p{Cf}\p{Zl}\p{Zp}]/u.test(value)) { log('API Key 必须是单行文本。'); continue; }
94
+ if (value) key = value;
95
+ if (key) return;
96
+ log('API Key 不能为空。');
97
+ }
98
+ };
99
+ const manualModel = async () => {
100
+ for (;;) {
101
+ const id = (await ask(`模型 ID${draft.model ? ` [${clean(draft.model)}]` : ''}: `)).trim() || draft.model;
102
+ if (!id || /[\u0000-\u001f\u007f]/.test(id)) { log('请输入有效的模型 ID。'); continue; }
103
+ draft.model = id;
104
+ return true;
105
+ }
106
+ };
107
+ const chooseModel = async () => {
108
+ let models;
109
+ try {
110
+ models = await run('正在获取当前凭据可见的模型', (signal) => discoverModels({ ...draft, apiKey: key, signal }));
111
+ if (!models.length) throw new Error('服务没有返回可选择的模型。');
112
+ } catch (error) {
113
+ log(`模型探测失败: ${errorText(error)}`);
114
+ log('请检查协议、地址和 Key。部分网关不提供模型列表,可手工填写 ID 后进行真实测试。');
115
+ const action = (await ask('输入 m 手工填写模型,或回车返回配置汇总: ')).trim().toLowerCase();
116
+ return action === 'm' ? manualModel() : false;
117
+ }
118
+ let page = 0;
119
+ const pageSize = 15;
120
+ for (;;) {
121
+ const pages = Math.ceil(models.length / pageSize);
122
+ log(`\n选择模型:共 ${models.length} 个,第 ${page + 1}/${pages} 页`);
123
+ models.slice(page * pageSize, (page + 1) * pageSize).forEach((model, index) => {
124
+ log(` ${page * pageSize + index + 1}. ${clean(model.id)}${model.id === draft.model ? ' [当前]' : ''}`);
125
+ });
126
+ log(' n 下一页 / p 上一页 / m 手工输入 / q 返回汇总');
127
+ const answer = (await ask(`模型编号${draft.model && models.some((model) => model.id === draft.model) ? ' [回车保留当前]' : ''}: `)).trim();
128
+ if (!answer && draft.model && models.some((model) => model.id === draft.model)) return true;
129
+ if (answer === 'q') return false;
130
+ if (answer === 'm') return manualModel();
131
+ if (answer === 'n') { page = Math.min(pages - 1, page + 1); continue; }
132
+ if (answer === 'p') { page = Math.max(0, page - 1); continue; }
133
+ const index = Number(answer) - 1;
134
+ if (Number.isInteger(index) && models[index]) { draft.model = models[index].id; return true; }
135
+ log('请输入有效编号,或使用 n/p 切换页面。');
136
+ }
137
+ };
138
+ const verify = async () => {
139
+ if (!key) { log('请先设置 API Key。'); return null; }
140
+ if (!draft.model && !await chooseModel()) return null;
141
+ summary();
142
+ log('将向上方服务发送一次最小模型请求,可能产生少量 API 费用。');
143
+ try {
144
+ const canonical = resolveProviderSettings(draft);
145
+ await run('正在测试模型连接', (signal) => testConnection({ ...canonical, apiKey: key, signal }));
146
+ log('✅ Connection successful!');
147
+ return { settings: canonical, apiKey: key };
148
+ } catch (error) {
149
+ log(`连接失败: ${errorText(error)}`);
150
+ log('尚未保存。请在配置汇总中选择要修改的项目。');
151
+ return null;
152
+ }
153
+ };
154
+
155
+ log('DeepSeek Harness MCP 终端配置');
156
+ if (!existing) {
157
+ await chooseProtocol();
158
+ await chooseUrl();
159
+ await chooseKey();
160
+ draft.model = '';
161
+ if (await chooseModel()) {
162
+ const result = await verify();
163
+ if (result) return result;
164
+ }
165
+ }
166
+ for (;;) {
167
+ summary();
168
+ log(' 5. 重新探测模型并选择');
169
+ log(' 6. 测试并保存');
170
+ log(' q. 退出,不保存');
171
+ const action = (await ask('选择操作 [6]: ')).trim().toLowerCase() || '6';
172
+ if (action === 'q') throw new Error('配置已取消,尚未保存。');
173
+ if (action === '1') await chooseProtocol();
174
+ else if (action === '2') await chooseUrl();
175
+ else if (action === '3') await chooseKey();
176
+ else if (action === '4') await manualModel();
177
+ else if (action === '5') await chooseModel();
178
+ else if (action === '6') {
179
+ const result = await verify();
180
+ if (result) return result;
181
+ } else log('请输入 1–6 或 q。');
182
+ }
183
+ }