mirror of
https://github.com/deepseek-ai/deepseek-harness.git
synced 2026-08-29 04:26:38 +00:00
refactor(apiproxy): retire migrated unary routes
This commit is contained in:
@@ -415,6 +415,11 @@
|
||||
"tests/**/*.ts"
|
||||
]
|
||||
},
|
||||
"packages/llm/llm": {
|
||||
"ignoreDependencies": [
|
||||
"zod"
|
||||
]
|
||||
},
|
||||
"packages/llm/llm-deepseek": {
|
||||
"entry": [
|
||||
"tests/**/*.spec.ts",
|
||||
|
||||
@@ -50,16 +50,10 @@
|
||||
"@deepseek-ai/dsh-attachment": "workspace:^",
|
||||
"@deepseek-ai/dsh-api-session-controller": "workspace:^",
|
||||
"@deepseek-ai/dsh-brand": "workspace:^",
|
||||
"@deepseek-ai/dsh-commands": "workspace:^",
|
||||
"@deepseek-ai/dsh-host-directory-picker": "workspace:^",
|
||||
"@deepseek-ai/dsh-llm": "workspace:^",
|
||||
"@deepseek-ai/dsh-native-command": "workspace:^",
|
||||
"@deepseek-ai/dsh-scope": "workspace:^",
|
||||
"@deepseek-ai/dsh-session": "workspace:^",
|
||||
"@deepseek-ai/dsh-session-persistence": "workspace:^",
|
||||
"@deepseek-ai/dsh-session-query": "workspace:^",
|
||||
"@deepseek-ai/dsh-settings": "workspace:^",
|
||||
"@deepseek-ai/dsh-skill": "workspace:^",
|
||||
"@deepseek-ai/dsh-util-crypto": "workspace:^",
|
||||
"@deepseek-ai/schemastery": "workspace:^",
|
||||
"fflate": "^0.8.2",
|
||||
@@ -67,14 +61,13 @@
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@deepseek-ai/cordis": "workspace:^",
|
||||
"@deepseek-ai/dsh-agent-presets": "workspace:^",
|
||||
"@deepseek-ai/dsh-invariants": "workspace:^"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@deepseek-ai/cordis": "workspace:^",
|
||||
"@deepseek-ai/dsh-agent-presets": "workspace:^",
|
||||
"@deepseek-ai/dsh-credentials": "workspace:^",
|
||||
"@deepseek-ai/dsh-invariants": "workspace:^",
|
||||
"@deepseek-ai/dsh-settings": "workspace:^",
|
||||
"@deepseek-ai/dsh-typert-protocol": "workspace:^",
|
||||
"@deepseek-ai/dsh-typert-registry": "workspace:^"
|
||||
}
|
||||
|
||||
@@ -4,19 +4,10 @@
|
||||
*/
|
||||
|
||||
import { homedir } from 'node:os'
|
||||
import { dirname } from 'node:path'
|
||||
import type { Context } from '@deepseek-ai/cordis'
|
||||
import type { ModelSelection } from '@deepseek-ai/dsh-agent'
|
||||
import type {} from '@deepseek-ai/dsh-agent-presets/types'
|
||||
import type { SessionId } from '@deepseek-ai/dsh-session'
|
||||
import { isUserInvocable } from '@deepseek-ai/dsh-skill'
|
||||
import {
|
||||
InvalidPresetIdError, PresetExistsError,
|
||||
PresetNotWritableError, UnknownPresetError,
|
||||
} from '@deepseek-ai/dsh-agent-presets'
|
||||
import type { ApiProxy, ConfigurableProviderView } from './api/index.ts'
|
||||
import { buildModelCatalog } from '@deepseek-ai/dsh-api-session-controller'
|
||||
import { SessionQueryError } from '@deepseek-ai/dsh-session-query'
|
||||
import { canOpenNativePath } from '@deepseek-ai/dsh-native-command'
|
||||
import type { ApiProxy } from './api/index.ts'
|
||||
import {
|
||||
DEFAULT_SESSION_LOG_COMPRESSION_LEVEL,
|
||||
flushLiveSessionLog,
|
||||
@@ -27,77 +18,30 @@ import {
|
||||
type SessionLogCompressionLevel,
|
||||
} from './session-export.ts'
|
||||
import type { SessionRawArtifact } from '@deepseek-ai/dsh-session-persistence'
|
||||
// Type-only edges: resolve the command-change stream and `ctx.get('skills')`.
|
||||
import type {} from '@deepseek-ai/dsh-commands'
|
||||
import type {} from '@deepseek-ai/dsh-skill'
|
||||
import type { ScopeKey } from '@deepseek-ai/dsh-scope'
|
||||
import type { RpcError, RpcRequest, RpcResponse } from './api/rpc.ts'
|
||||
import { canOpenNativePath, openNativePath, openNativeTextFile } from './native-path-opener.ts'
|
||||
|
||||
/** Read live abort state across awaits without treating it as synchronously immutable. */
|
||||
function isAborted(signal: AbortSignal): boolean {
|
||||
return signal.aborted
|
||||
}
|
||||
import type { RpcRequest, RpcResponse } from './api/rpc.ts'
|
||||
|
||||
/** Wrap an ok result echoing the request's rpcId. */
|
||||
function ok<T>(request: RpcRequest<unknown>, value: T): RpcResponse<T> {
|
||||
return { rpcId: request.rpcId, result: { ok: true, value } }
|
||||
}
|
||||
|
||||
/** Wrap an error result echoing the request's rpcId. */
|
||||
function err<T>(request: RpcRequest<unknown>, error: RpcError): RpcResponse<T> {
|
||||
return { rpcId: request.rpcId, result: { ok: false, error } }
|
||||
}
|
||||
|
||||
/** Deployment metadata and Host integrations consumed by the API implementation. */
|
||||
export interface ApiProxyDefaults {
|
||||
/** Current deployment model selection reported by `host.describe`. */
|
||||
defaultModelSelection: () => ModelSelection
|
||||
/** Project hint reported by `host.describe`; must match Session Controller's default cwd. */
|
||||
cwd: string
|
||||
/** Native open-with-default-application; injectable for carrier tests. */
|
||||
openPath?: (path: string, signal: AbortSignal) => Promise<void>
|
||||
/** Native text-editor handoff; injectable for settings-document tests. */
|
||||
openTextFile?: (path: string, signal: AbortSignal) => Promise<void>
|
||||
/** Validated DEFLATE level for session-log ZIP entries; defaults to 6. */
|
||||
sessionExportCompressionLevel?: SessionLogCompressionLevel
|
||||
/**
|
||||
* Whether handing a path to the native opener can work at all — the
|
||||
* `hasDocument` capability the preset roster reports, and the switch
|
||||
* between opening a preset directory and answering its path as text.
|
||||
* Absent, an injected `openPath` counts as openable and everything else
|
||||
* falls back to platform detection ({@link canOpenNativePath}).
|
||||
* Absent, platform detection decides ({@link canOpenNativePath}).
|
||||
*/
|
||||
canOpenPath?: () => boolean
|
||||
}
|
||||
|
||||
/** The roster is absent: this deployment composes no agent presets at all. */
|
||||
function noRoster(agentPreset: string): RpcError {
|
||||
return {
|
||||
code: 'agent-preset-not-found',
|
||||
message: 'this deployment composes no agent presets',
|
||||
details: { agentPreset, available: [] },
|
||||
}
|
||||
}
|
||||
|
||||
/** Map one authoring/roster failure onto its wire code. */
|
||||
function presetError(agentPreset: string, error: unknown): RpcError {
|
||||
if (error instanceof UnknownPresetError) {
|
||||
return {
|
||||
code: 'agent-preset-not-found',
|
||||
message: error.message,
|
||||
details: { agentPreset: error.presetId, available: [...error.available] },
|
||||
}
|
||||
}
|
||||
if (error instanceof PresetNotWritableError) {
|
||||
return { code: 'agent-preset-read-only', message: error.message, details: { agentPreset, reason: error.message } }
|
||||
}
|
||||
if (error instanceof InvalidPresetIdError || error instanceof PresetExistsError) {
|
||||
return { code: 'agent-preset-invalid', message: error.message, details: { agentPreset, reason: error.message } }
|
||||
}
|
||||
return { code: 'internal', message: `agent preset "${agentPreset}": ${String(error)}`, details: {} }
|
||||
}
|
||||
|
||||
/**
|
||||
* Implement ApiProxy over a composed host context.
|
||||
* @param ctx - a context with the Host spine mounted.
|
||||
@@ -107,75 +51,10 @@ function presetError(agentPreset: string, error: unknown): RpcError {
|
||||
export function createApiProxy(ctx: Context, defaults: ApiProxyDefaults): ApiProxy {
|
||||
const sessionExportCompressionLevel = defaults.sessionExportCompressionLevel
|
||||
?? DEFAULT_SESSION_LOG_COMPRESSION_LEVEL
|
||||
/** Resolve a Session's live or standing preset scope without resuming it. */
|
||||
async function sessionScopeFor(
|
||||
sessionId: SessionId,
|
||||
agentPreset: string | undefined,
|
||||
): Promise<ScopeKey | undefined> {
|
||||
const live = ctx.get('agents')?.get(sessionId)
|
||||
if (live !== undefined) return live
|
||||
const presets = ctx.get('agentPresets')
|
||||
if (presets === undefined) return undefined
|
||||
try {
|
||||
return await presets.standingKeyFor(agentPreset)
|
||||
} catch {
|
||||
// An unknown or unusable recorded preset falls back to the global registry.
|
||||
return undefined
|
||||
}
|
||||
}
|
||||
|
||||
/** Missing-service report shared by the settings domain (skills-domain stance). */
|
||||
function settingsAbsent(): RpcError {
|
||||
return { code: 'internal', message: 'settings service is absent: this deployment does not mount a settings provider (e.g. @deepseek-ai/dsh-settings-file) in its composition', details: {} }
|
||||
}
|
||||
|
||||
/** Open one Host-resolved target and map native failures onto the wire vocabulary. */
|
||||
async function openTarget(
|
||||
request: RpcRequest<unknown>, path: string, signal: AbortSignal,
|
||||
open: (path: string, signal: AbortSignal) => Promise<void>,
|
||||
): Promise<RpcResponse<{ opened: true }>> {
|
||||
try {
|
||||
await open(path, signal)
|
||||
return ok(request, { opened: true as const })
|
||||
} catch (error: unknown) {
|
||||
if (signal.aborted) {
|
||||
return err(request, {
|
||||
code: 'cancelled',
|
||||
message: 'path open was aborted',
|
||||
details: {},
|
||||
})
|
||||
}
|
||||
return err(request, {
|
||||
code: 'internal',
|
||||
message: `path open failed: ${error instanceof Error ? error.message : String(error)}`,
|
||||
details: {},
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/** Open one Host-resolved path with its default application. */
|
||||
function openPath(
|
||||
request: RpcRequest<unknown>, path: string, signal: AbortSignal,
|
||||
): Promise<RpcResponse<{ opened: true }>> {
|
||||
const open = defaults.openPath
|
||||
?? ((target: string, openSignal: AbortSignal) => openNativePath(target, openSignal))
|
||||
return openTarget(request, path, signal, open)
|
||||
}
|
||||
|
||||
/** Open one Host-resolved text document in a native editor. */
|
||||
function openTextFile(
|
||||
request: RpcRequest<unknown>, path: string, signal: AbortSignal,
|
||||
): Promise<RpcResponse<{ opened: true }>> {
|
||||
const open = defaults.openTextFile
|
||||
?? ((target: string, openSignal: AbortSignal) => openNativeTextFile(target, openSignal))
|
||||
return openTarget(request, path, signal, open)
|
||||
}
|
||||
|
||||
/** Whether this deployment can hand a path to a native opener at all. */
|
||||
function canOpenPaths(): boolean {
|
||||
if (defaults.canOpenPath !== undefined) return defaults.canOpenPath()
|
||||
// An injected opener is by definition usable; otherwise ask the platform.
|
||||
return defaults.openPath !== undefined || canOpenNativePath()
|
||||
return canOpenNativePath()
|
||||
}
|
||||
|
||||
return {
|
||||
@@ -198,210 +77,6 @@ export function createApiProxy(ctx: Context, defaults: ApiProxyDefaults): ApiPro
|
||||
}))
|
||||
},
|
||||
|
||||
async openPath(request, signal) {
|
||||
return openPath(request, request.payload.path, signal)
|
||||
},
|
||||
},
|
||||
|
||||
agentPresets: {
|
||||
// Only the desktop opener remains here: the roster, selection, and
|
||||
// authoring calls are the AgentPresets service's own Remote namespace.
|
||||
async openDocument(request, signal) {
|
||||
const { agentPreset } = request.payload
|
||||
const presets = ctx.get('agentPresets')
|
||||
if (presets === undefined) return err(request, noRoster(agentPreset))
|
||||
try {
|
||||
const preset = await presets.resolve(agentPreset)
|
||||
// Same line as copy/remove draw: the shipped install is not the
|
||||
// user's to manage, and pointing an editor into it invites edits an
|
||||
// upgrade will silently overwrite.
|
||||
if (preset.trust !== 'user') {
|
||||
throw new PresetNotWritableError(preset.id, 'it ships with the deployment')
|
||||
}
|
||||
// The id resolved against the Host's own roots is what selects the
|
||||
// directory — no browser payload carries a path in either direction
|
||||
// unless the deployment has no opener to hand it to.
|
||||
const directory = dirname(preset.path)
|
||||
if (!canOpenPaths()) return ok(request, { opened: false as const, path: directory })
|
||||
return await openPath(request, directory, signal)
|
||||
} catch (error: unknown) {
|
||||
return err(request, presetError(agentPreset, error))
|
||||
}
|
||||
},
|
||||
},
|
||||
|
||||
skills: {
|
||||
// Skill lookup never creates or resumes an agent: the session address
|
||||
// resolves to a canonical cwd from the host-resident session header, and
|
||||
// the view scope is the live agent or the preset's standing key.
|
||||
async list(request) {
|
||||
const { sessionId } = request.payload
|
||||
let cwd: string | undefined
|
||||
let agentPreset: string | undefined
|
||||
try {
|
||||
using observation = await ctx.sessionQuery.observeSession(sessionId)
|
||||
if (observation.projections === undefined) {
|
||||
throw new Error('skill catalog requires a projected Session observation')
|
||||
}
|
||||
cwd = observation.header.cwd
|
||||
agentPreset = observation.projections.values.agentPreset ?? undefined
|
||||
} catch (error: unknown) {
|
||||
if (error instanceof SessionQueryError
|
||||
&& error.code === 'SESSION_QUERY_SESSION_NOT_FOUND') {
|
||||
return err(request, {
|
||||
code: 'session-not-found',
|
||||
message: `session "${sessionId}" not found`,
|
||||
details: { sessionId },
|
||||
})
|
||||
}
|
||||
return err(request, {
|
||||
code: 'internal',
|
||||
message: `session "${sessionId}" could not be inspected: ${String(error)}`,
|
||||
details: {},
|
||||
})
|
||||
}
|
||||
if (cwd === undefined) {
|
||||
// Every served session records its project at create time; a
|
||||
// cwd-less header is a pre-project legacy log (not served).
|
||||
return err(request, { code: 'internal', message: `session "${sessionId}" has no project cwd`, details: {} })
|
||||
}
|
||||
// The host registry is layered per scope and serves every session. A
|
||||
// composition may still realm-mount its own registry instead; that
|
||||
// instance is invisible to host contexts, so address it through the
|
||||
// live agent (`agents.get` keeps the no-side-effect stance above).
|
||||
const live = ctx.agents.get(sessionId)
|
||||
const presets = ctx.get('agentPresets')
|
||||
const scoped = live === undefined ? undefined : presets?.serviceFor(live, 'skills')
|
||||
// A missing service means no composition mounts dsh-skill, not an
|
||||
// empty catalog. `ctx.get` also
|
||||
// keeps this handler independent of the gateway plugin's inject list
|
||||
// (an undeclared `ctx.skills` property read fails the reflect proxy).
|
||||
const skillRegistry = scoped ?? ctx.get('skills')
|
||||
if (skillRegistry === undefined) {
|
||||
return err(request, { code: 'internal', message: 'skill registry is absent: neither this session\'s agent preset nor the host composition mounts @deepseek-ai/dsh-skill', details: {} })
|
||||
}
|
||||
// Resolve the live or recorded preset scope so the catalog matches the
|
||||
// Session composition without resuming its Agent.
|
||||
const scope = await sessionScopeFor(sessionId, agentPreset)
|
||||
try {
|
||||
const skills = (await skillRegistry.list({ cwd, scope })).filter(isUserInvocable)
|
||||
return ok(request, {
|
||||
skills: skills.map(skill => ({
|
||||
name: skill.name,
|
||||
description: skill.description,
|
||||
...skill.whenToUse === undefined ? {} : { whenToUse: skill.whenToUse },
|
||||
modelInvocable: skill.invocation.modelInvocable,
|
||||
})),
|
||||
})
|
||||
} catch (error: unknown) {
|
||||
return err(request, { code: 'internal', message: `skill listing failed: ${String(error)}`, details: {} })
|
||||
}
|
||||
},
|
||||
},
|
||||
|
||||
settings: {
|
||||
async openDocument(request, signal) {
|
||||
const settings = ctx.get('settings')
|
||||
if (settings === undefined) return err(request, settingsAbsent())
|
||||
if (isAborted(signal)) {
|
||||
return err(request, {
|
||||
code: 'cancelled',
|
||||
message: 'settings document open was aborted',
|
||||
details: {},
|
||||
})
|
||||
}
|
||||
let path: string | undefined
|
||||
try {
|
||||
path = await settings.prepareDocument()
|
||||
} catch (error: unknown) {
|
||||
if (isAborted(signal)) {
|
||||
return err(request, {
|
||||
code: 'cancelled',
|
||||
message: 'settings document preparation was aborted',
|
||||
details: {},
|
||||
})
|
||||
}
|
||||
return err(request, {
|
||||
code: 'internal',
|
||||
message: `settings document preparation failed: ${error instanceof Error ? error.message : String(error)}`,
|
||||
details: {},
|
||||
})
|
||||
}
|
||||
if (path === undefined) {
|
||||
return err(request, {
|
||||
code: 'internal',
|
||||
message: 'settings provider has no local document to open',
|
||||
details: {},
|
||||
})
|
||||
}
|
||||
if (isAborted(signal)) {
|
||||
return err(request, {
|
||||
code: 'cancelled',
|
||||
message: 'settings document open was aborted',
|
||||
details: {},
|
||||
})
|
||||
}
|
||||
return openTextFile(request, path, signal)
|
||||
},
|
||||
},
|
||||
|
||||
llm: {
|
||||
providers(request) {
|
||||
const registered = ctx.llm.listProviders()
|
||||
const active = new Set(registered.map(provider => provider.id))
|
||||
const directory = ctx.llm.listConfigurableProviders()
|
||||
const declared = new Set(directory.map(entry => entry.provider))
|
||||
const views: ConfigurableProviderView[] = directory.map(entry => ({
|
||||
provider: entry.provider,
|
||||
displayName: entry.displayName,
|
||||
settingsNs: entry.settingsNs,
|
||||
settingsPath: [...entry.settingsPath],
|
||||
active: active.has(entry.provider),
|
||||
...entry.declared === undefined ? {} : { declared: entry.declared },
|
||||
}))
|
||||
// Routes registered without a directory declaration still appear —
|
||||
// they exist and serve models — just with no settings address. No
|
||||
// adapter claimed them, so nothing can say whether they are shipped.
|
||||
for (const provider of registered) {
|
||||
if (declared.has(provider.id)) continue
|
||||
views.push({
|
||||
provider: provider.id,
|
||||
displayName: provider.name,
|
||||
settingsNs: '',
|
||||
settingsPath: [],
|
||||
active: true,
|
||||
})
|
||||
}
|
||||
return Promise.resolve(ok(request, { providers: views }))
|
||||
},
|
||||
|
||||
async models(request) {
|
||||
return ok(request, await buildModelCatalog(ctx, defaults.defaultModelSelection()))
|
||||
},
|
||||
|
||||
async discoverModels(request, signal) {
|
||||
const { settingsNs, provider, baseURL, api, apiKey } = request.payload
|
||||
try {
|
||||
const models = await ctx.llm.discoverModels(settingsNs, {
|
||||
...provider === undefined ? {} : { provider },
|
||||
...baseURL === undefined ? {} : { baseURL },
|
||||
...api === undefined ? {} : { api },
|
||||
...apiKey === undefined ? {} : { apiKey },
|
||||
...signal === undefined ? {} : { signal },
|
||||
})
|
||||
return ok(request, { models })
|
||||
} catch (error: unknown) {
|
||||
// Every failure here is the user's next move, not a transport fault:
|
||||
// a wrong endpoint, a rejected key, or a protocol with no listing all
|
||||
// end at the same place — fill the models in by hand. The details
|
||||
// repeat only what the caller already sent, never the credential.
|
||||
return err(request, {
|
||||
code: 'model-discovery-failed',
|
||||
message: error instanceof Error ? error.message : String(error),
|
||||
details: { settingsNs, ...baseURL === undefined ? {} : { baseURL } },
|
||||
})
|
||||
}
|
||||
},
|
||||
},
|
||||
|
||||
downloads: {
|
||||
|
||||
@@ -1,19 +0,0 @@
|
||||
/**
|
||||
* agent-presets domain zod schemas (names derived from map keys:
|
||||
* agentPresetOpenDocumentRequestSchema / agentPresetOpenDocumentValueSchema).
|
||||
*/
|
||||
|
||||
import { z } from 'zod'
|
||||
import type { RequestPayload, ResponseValue } from './rpc-map.ts'
|
||||
import type { Wire } from './rpc.schema.ts'
|
||||
|
||||
/** agentPreset.openDocument request payload. */
|
||||
export const agentPresetOpenDocumentRequestSchema = z.object({
|
||||
agentPreset: z.string().min(1),
|
||||
}) satisfies z.ZodType<Wire<RequestPayload<'agentPreset.openDocument'>>>
|
||||
|
||||
/** agentPreset.openDocument response value. */
|
||||
export const agentPresetOpenDocumentValueSchema = z.union([
|
||||
z.object({ opened: z.literal(true) }),
|
||||
z.object({ opened: z.literal(false), path: z.string() }),
|
||||
]) satisfies z.ZodType<Wire<ResponseValue<'agentPreset.openDocument'>>>
|
||||
@@ -1,25 +0,0 @@
|
||||
/**
|
||||
* agent-presets domain contract: handing one preset's directory to the
|
||||
* platform opener, which is the only agent-preset call still carried here.
|
||||
*
|
||||
* The roster and its authoring calls are the AgentPresets service's own Remote
|
||||
* namespace. This one stays because the opener is a Host desktop integration
|
||||
* rather than a preset operation.
|
||||
*/
|
||||
|
||||
import type { RpcRequest, RpcResponse } from './rpc.ts'
|
||||
|
||||
/** agent-preset-domain unary methods (the map key agentPreset.* of RpcMethodMap). */
|
||||
export interface AgentPresetsApi {
|
||||
/**
|
||||
* Hand one locally authored preset's DIRECTORY to the platform opener, for
|
||||
* editing the files, which are the only composition editor. The request
|
||||
* carries an id, never a path — the Host resolves it — so no browser
|
||||
* payload can select an arbitrary filesystem target. Where the deployment
|
||||
* has no native opener (`canOpenPath: false` on `host.describe`), the reply
|
||||
* carries the resolved directory for the surface to show as text instead.
|
||||
* Shipped presets are refused: their install is not the user's to manage.
|
||||
*/
|
||||
openDocument(request: RpcRequest<{ agentPreset: string }>, signal: AbortSignal):
|
||||
Promise<RpcResponse<{ opened: true } | { opened: false; path: string }>>
|
||||
}
|
||||
@@ -19,13 +19,3 @@ export const hostDescribeValueSchema = z.object({
|
||||
home: z.string(),
|
||||
canOpenPath: z.boolean(),
|
||||
}) satisfies z.ZodType<Wire<ResponseValue<'host.describe'>>>
|
||||
|
||||
/** host.openPath request payload. */
|
||||
export const hostOpenPathRequestSchema = z.object({
|
||||
path: z.string().min(1),
|
||||
}) satisfies z.ZodType<Wire<RequestPayload<'host.openPath'>>>
|
||||
|
||||
/** host.openPath response value. */
|
||||
export const hostOpenPathValueSchema = z.object({
|
||||
opened: z.literal(true),
|
||||
}) satisfies z.ZodType<Wire<ResponseValue<'host.openPath'>>>
|
||||
|
||||
@@ -27,14 +27,4 @@ export interface HostApi {
|
||||
canOpenPath: boolean
|
||||
}>>
|
||||
|
||||
/**
|
||||
* Open a filesystem path with the operating system's default application
|
||||
* (Finder / Explorer / xdg-open hand-off). The browser carrier's
|
||||
* prefix-wide trust and authentication checks cover this method like every
|
||||
* other `/api` request.
|
||||
*/
|
||||
openPath(
|
||||
request: RpcRequest<{ path: string }>,
|
||||
signal: AbortSignal,
|
||||
): Promise<RpcResponse<{ opened: true }>>
|
||||
}
|
||||
|
||||
@@ -5,19 +5,11 @@
|
||||
*/
|
||||
|
||||
import type { HostApi } from './host.ts'
|
||||
import type { AgentPresetsApi } from './agent-presets.ts'
|
||||
import type { SkillsApi } from './skills.ts'
|
||||
import type { SettingsApi } from './settings.ts'
|
||||
import type { LlmApi } from './llm.ts'
|
||||
import type { DownloadsApi } from './downloads.ts'
|
||||
|
||||
/** Root interface of the unified API. New client-request domain = one new file pair + one field here + one map row. */
|
||||
export interface ApiProxy {
|
||||
host: HostApi
|
||||
skills: SkillsApi
|
||||
agentPresets: AgentPresetsApi
|
||||
settings: SettingsApi
|
||||
llm: LlmApi
|
||||
/** Host-only download surfaces (GET, no wire envelope); absent from IApiClient. */
|
||||
downloads: DownloadsApi
|
||||
}
|
||||
@@ -28,10 +20,6 @@ export type {
|
||||
ModelReasoningEffort, ModelSelection,
|
||||
} from '@deepseek-ai/dsh-api-session-controller/types'
|
||||
export type { HostApi } from './host.ts'
|
||||
export type { SkillsApi, SkillEntry } from './skills.ts'
|
||||
export type { AgentPresetsApi } from './agent-presets.ts'
|
||||
export type { SettingsApi } from './settings.ts'
|
||||
export type { ConfigurableProviderView, DiscoveredModelView, LlmApi } from './llm.ts'
|
||||
export type { DownloadsApi } from './downloads.ts'
|
||||
|
||||
// ---- Message layer: narrow forms (domain-signature view) ----
|
||||
|
||||
@@ -1,115 +0,0 @@
|
||||
/**
|
||||
* llm domain zod schemas (names derived from map keys: llmProvidersRequestSchema /
|
||||
* llmProvidersValueSchema / llmModelsRequestSchema / llmModelsValueSchema).
|
||||
*/
|
||||
|
||||
import { z } from 'zod'
|
||||
import type { RequestPayload, ResponseValue } from './rpc-map.ts'
|
||||
import type { Wire } from './rpc.schema.ts'
|
||||
import type { ConfigurableProviderView, DiscoveredModelView } from './llm.ts'
|
||||
import type {
|
||||
ModelCatalogFailure,
|
||||
ModelCatalogModel,
|
||||
ModelSelection,
|
||||
ModelProviderGroup,
|
||||
ModelReasoning,
|
||||
ModelReasoningEffort,
|
||||
} from '@deepseek-ai/dsh-api-session-controller/types'
|
||||
|
||||
/** One adapter-owned reasoning effort. */
|
||||
const modelReasoningEffortSchema = z.object({
|
||||
id: z.string().min(1),
|
||||
name: z.string().min(1),
|
||||
description: z.string().optional(),
|
||||
}) satisfies z.ZodType<Wire<ModelReasoningEffort>>
|
||||
|
||||
/** Exact-model reasoning metadata. */
|
||||
const modelReasoningSchema = z.object({
|
||||
efforts: z.array(modelReasoningEffortSchema).min(1),
|
||||
defaultEffort: z.string().min(1).optional(),
|
||||
}) satisfies z.ZodType<Wire<ModelReasoning>>
|
||||
|
||||
/** One advisory model entry inside a provider group. */
|
||||
const modelCatalogModelSchema = z.object({
|
||||
id: z.string().min(1),
|
||||
name: z.string().min(1),
|
||||
description: z.string().optional(),
|
||||
reasoning: modelReasoningSchema.optional(),
|
||||
}) satisfies z.ZodType<Wire<ModelCatalogModel>>
|
||||
|
||||
/** One successfully loaded provider group. */
|
||||
const modelProviderGroupSchema = z.object({
|
||||
id: z.string().min(1),
|
||||
name: z.string().min(1),
|
||||
models: z.array(modelCatalogModelSchema),
|
||||
}) satisfies z.ZodType<Wire<ModelProviderGroup>>
|
||||
|
||||
/** One provider-local catalog failure. */
|
||||
const modelCatalogFailureSchema = z.object({
|
||||
id: z.string().min(1),
|
||||
name: z.string().min(1),
|
||||
message: z.string(),
|
||||
}) satisfies z.ZodType<Wire<ModelCatalogFailure>>
|
||||
|
||||
/** Complete model selection used as the Host default. */
|
||||
const modelSelectionSchema = z.object({
|
||||
provider: z.string().min(1),
|
||||
model: z.string().min(1),
|
||||
reasoningEffort: z.string().min(1).optional(),
|
||||
}) satisfies z.ZodType<Wire<ModelSelection>>
|
||||
|
||||
/** ConfigurableProviderView row of llm.providers. */
|
||||
export const configurableProviderViewSchema = z.object({
|
||||
provider: z.string().min(1),
|
||||
displayName: z.string().min(1),
|
||||
settingsNs: z.string(),
|
||||
settingsPath: z.array(z.string()),
|
||||
active: z.boolean(),
|
||||
declared: z.boolean().optional(),
|
||||
}) satisfies z.ZodType<Wire<ConfigurableProviderView>>
|
||||
|
||||
/** llm.providers request payload. */
|
||||
export const llmProvidersRequestSchema = z.object({}) satisfies z.ZodType<Wire<RequestPayload<'llm.providers'>>>
|
||||
|
||||
/** llm.providers response value. */
|
||||
export const llmProvidersValueSchema = z.object({
|
||||
providers: z.array(configurableProviderViewSchema),
|
||||
}) satisfies z.ZodType<Wire<ResponseValue<'llm.providers'>>>
|
||||
|
||||
/** llm.models request payload. */
|
||||
export const llmModelsRequestSchema = z.object({}) satisfies z.ZodType<Wire<RequestPayload<'llm.models'>>>
|
||||
|
||||
/** llm.models response value. */
|
||||
export const llmModelsValueSchema = z.object({
|
||||
default: modelSelectionSchema,
|
||||
routableProviders: z.array(z.string().min(1)),
|
||||
groups: z.array(modelProviderGroupSchema),
|
||||
failures: z.array(modelCatalogFailureSchema),
|
||||
}) satisfies z.ZodType<Wire<ResponseValue<'llm.models'>>>
|
||||
|
||||
/** DiscoveredModelView row of llm.discoverModels. */
|
||||
export const discoveredModelViewSchema = z.object({
|
||||
id: z.string().min(1),
|
||||
name: z.string().min(1).optional(),
|
||||
contextWindow: z.number().int().positive().optional(),
|
||||
maxTokens: z.number().int().positive().optional(),
|
||||
}) satisfies z.ZodType<Wire<DiscoveredModelView>>
|
||||
|
||||
/** llm.discoverModels request payload. */
|
||||
export const llmDiscoverModelsRequestSchema = z.object({
|
||||
settingsNs: z.string().min(1),
|
||||
provider: z.string().min(1).optional(),
|
||||
baseURL: z.string().min(1).optional(),
|
||||
api: z.string().min(1).optional(),
|
||||
// Write-only at the host: used for this one interrogation, never stored and
|
||||
// never returned. It does ride the client's outgoing envelope like every
|
||||
// other secret-bearing payload (`settings/update`), which
|
||||
// `subscribeEnvelopes()` observers can see — redacting that tap is a
|
||||
// configuration-plane-wide change, not this method's to make alone.
|
||||
apiKey: z.string().min(1).optional(),
|
||||
}) satisfies z.ZodType<Wire<RequestPayload<'llm.discoverModels'>>>
|
||||
|
||||
/** llm.discoverModels response value. */
|
||||
export const llmDiscoverModelsValueSchema = z.object({
|
||||
models: z.array(discoveredModelViewSchema),
|
||||
}) satisfies z.ZodType<Wire<ResponseValue<'llm.discoverModels'>>>
|
||||
@@ -1,90 +0,0 @@
|
||||
/**
|
||||
* llm domain contract: host-scoped provider topology for configuration
|
||||
* surfaces. `llm.providers` merges the configurable-provider directory
|
||||
* (which providers CAN be configured, and where their settings live) with the
|
||||
* live route registry; `llm.models` is the session-independent model catalog.
|
||||
* Clients invalidate from the forwarded `llm/adapters-updated` and
|
||||
* `settings/document-updated` owner events.
|
||||
*/
|
||||
|
||||
import type { RpcRequest, RpcResponse } from './rpc.ts'
|
||||
import type {
|
||||
ModelCatalog,
|
||||
} from '@deepseek-ai/dsh-api-session-controller/types'
|
||||
|
||||
/** Wire view of one configurable provider. */
|
||||
export interface ConfigurableProviderView {
|
||||
/** Provider route key (`deepseek-official`, `openai`, …). */
|
||||
provider: string
|
||||
/** Human-readable name for configuration surfaces. */
|
||||
displayName: string
|
||||
/** Settings namespace whose section configures this provider. */
|
||||
settingsNs: string
|
||||
/** Path from that section's root to the provider's profile object (empty = whole section). */
|
||||
settingsPath: string[]
|
||||
/** Whether the route is currently registered (its models are requestable). */
|
||||
active: boolean
|
||||
/**
|
||||
* Whether the owning adapter knows this route only because configuration
|
||||
* declared it. Absent when the adapter draws no such distinction, so a
|
||||
* surface must treat absence as "unknown", not as "shipped".
|
||||
*/
|
||||
declared?: boolean
|
||||
}
|
||||
|
||||
/** Llm-domain unary methods (the map keys llm.* of RpcMethodMap). */
|
||||
export interface LlmApi {
|
||||
/**
|
||||
* List every configurable provider with its live/dormant state, in
|
||||
* directory declaration order. Routes registered outside the directory
|
||||
* (an adapter that never declared configurability) are appended with their
|
||||
* registration identity and no settings address.
|
||||
*/
|
||||
providers(request: RpcRequest<{}>): Promise<RpcResponse<{ providers: ConfigurableProviderView[] }>>
|
||||
|
||||
/**
|
||||
* Host-scoped model catalog over every registered provider route: the
|
||||
* settings surface's models view, needing no session. Per-provider listing
|
||||
* failures ride `failures` without failing the sound groups.
|
||||
*/
|
||||
models(request: RpcRequest<{}>): Promise<RpcResponse<ModelCatalog>>
|
||||
|
||||
/**
|
||||
* Interrogate a provider endpoint the configuration surface is still
|
||||
* drafting, and return the models it advertises for the user to adopt.
|
||||
*
|
||||
* The payload is the draft, not a stored route: `settingsNs` selects the
|
||||
* adapter family that answers, and the rest comes from the form. `provider`
|
||||
* names the route being edited when there is one — an adapter that already
|
||||
* describes that route answers from its own registry, with better metadata
|
||||
* and no network call, and needs no endpoint. A route it does not describe is
|
||||
* asked over the wire, which is what `baseURL`, `api`, and `apiKey` are for.
|
||||
*
|
||||
* Nothing is written — the reply is candidates, and only a later
|
||||
* `settings.mutate` decides what a route serves. `apiKey` is accepted here
|
||||
* but never stored or returned; a provider whose key is already stored omits
|
||||
* it and the endpoint answers unauthenticated or refuses.
|
||||
*/
|
||||
discoverModels(
|
||||
request: RpcRequest<{
|
||||
settingsNs: string
|
||||
provider?: string
|
||||
baseURL?: string
|
||||
api?: string
|
||||
apiKey?: string
|
||||
}>,
|
||||
signal?: AbortSignal,
|
||||
): Promise<RpcResponse<{ models: DiscoveredModelView[] }>>
|
||||
}
|
||||
|
||||
/** Wire view of one model an interrogated endpoint advertises. */
|
||||
export interface DiscoveredModelView {
|
||||
/** Model id the endpoint accepts. */
|
||||
id: string
|
||||
/** Human-readable name when the endpoint supplies one. */
|
||||
name?: string
|
||||
/** Maximum combined request and response context, when disclosed. */
|
||||
contextWindow?: number
|
||||
/** Maximum output tokens, when disclosed. */
|
||||
maxTokens?: number
|
||||
}
|
||||
@@ -4,10 +4,6 @@
|
||||
*/
|
||||
|
||||
import type { HostApi } from './host.ts'
|
||||
import type { AgentPresetsApi } from './agent-presets.ts'
|
||||
import type { SkillsApi } from './skills.ts'
|
||||
import type { SettingsApi } from './settings.ts'
|
||||
import type { LlmApi } from './llm.ts'
|
||||
import type { RpcResponse } from './rpc.ts'
|
||||
|
||||
/**
|
||||
@@ -17,13 +13,6 @@ import type { RpcResponse } from './rpc.ts'
|
||||
*/
|
||||
export interface RpcMethodMap {
|
||||
'host.describe': HostApi['describe']
|
||||
'host.openPath': HostApi['openPath']
|
||||
'skill.list': SkillsApi['list']
|
||||
'agentPreset.openDocument': AgentPresetsApi['openDocument']
|
||||
'settings.openDocument': SettingsApi['openDocument']
|
||||
'llm.providers': LlmApi['providers']
|
||||
'llm.models': LlmApi['models']
|
||||
'llm.discoverModels': LlmApi['discoverModels']
|
||||
}
|
||||
|
||||
/** Business request payload of method K (reaches through the RpcRequest narrow form to payload). */
|
||||
|
||||
@@ -41,7 +41,6 @@ export const rpcErrorSchema: z.ZodType<RpcError> = z.discriminatedUnion('code',
|
||||
z.object({ code: z.literal('agent-preset-not-found'), message: z.string(), details: z.object({ agentPreset: z.string(), available: z.array(z.string()) }) }),
|
||||
z.object({ code: z.literal('agent-preset-invalid'), message: z.string(), details: z.object({ agentPreset: z.string(), reason: z.string() }) }),
|
||||
z.object({ code: z.literal('agent-busy'), message: z.string(), details: z.object({ reason: z.string() }) }),
|
||||
z.object({ code: z.literal('model-discovery-failed'), message: z.string(), details: z.object({ settingsNs: z.string(), baseURL: z.string().optional() }) }),
|
||||
z.object({ code: z.literal('internal'), message: z.string(), details: z.object({}) }),
|
||||
]) as unknown as z.ZodType<RpcError>
|
||||
|
||||
|
||||
@@ -36,15 +36,6 @@ export interface RpcErrorDetailsMap {
|
||||
'agent-preset-not-found': { agentPreset: string; available: readonly string[] }
|
||||
'agent-preset-invalid': { agentPreset: string; reason: string }
|
||||
'agent-busy': { reason: string }
|
||||
/**
|
||||
* Interrogating a draft provider endpoint did not produce a model listing:
|
||||
* no adapter family serves the namespace, the protocol has no listing this
|
||||
* build can read, or the endpoint was unreachable, refused the credential,
|
||||
* or answered with something else. The message is the adapter's own text —
|
||||
* it is what the form shows before falling back to hand-entry — and the
|
||||
* details name the endpoint asked, never the credential offered.
|
||||
*/
|
||||
'model-discovery-failed': { settingsNs: string; baseURL?: string }
|
||||
'internal': {}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,16 +0,0 @@
|
||||
/**
|
||||
* settings domain zod schemas (names derived from map keys:
|
||||
* settingsOpenDocumentRequestSchema / settingsOpenDocumentValueSchema).
|
||||
*/
|
||||
|
||||
import { z } from 'zod'
|
||||
import type { RequestPayload, ResponseValue } from './rpc-map.ts'
|
||||
import type { Wire } from './rpc.schema.ts'
|
||||
|
||||
/** settings.openDocument request payload. */
|
||||
export const settingsOpenDocumentRequestSchema = z.object({}) satisfies z.ZodType<Wire<RequestPayload<'settings.openDocument'>>>
|
||||
|
||||
/** settings.openDocument response value. */
|
||||
export const settingsOpenDocumentValueSchema = z.object({
|
||||
opened: z.literal(true),
|
||||
}) satisfies z.ZodType<Wire<ResponseValue<'settings.openDocument'>>>
|
||||
@@ -1,22 +0,0 @@
|
||||
/**
|
||||
* settings domain contract: what remains of the web face of the user-settings
|
||||
* seam (`ctx.settings`) once the redacted read and the path-addressed write
|
||||
* moved to the `settings` Remote namespace. Only the local-document handoff
|
||||
* stays here, because opening a Host file is a platform action rather than a
|
||||
* settings read.
|
||||
*/
|
||||
|
||||
import type { RpcRequest, RpcResponse } from './rpc.ts'
|
||||
|
||||
/** Settings-domain unary methods (the map keys settings.* of RpcMethodMap). */
|
||||
export interface SettingsApi {
|
||||
/**
|
||||
* Materialize the configured local document when absent and ask the Host to
|
||||
* hand it to the platform text-document opener. macOS forces a text editor;
|
||||
* Linux and Windows use the desktop file association. The request carries
|
||||
* no path, so the browser cannot choose an arbitrary Host filesystem target.
|
||||
*/
|
||||
openDocument(
|
||||
request: RpcRequest<{}>, signal: AbortSignal,
|
||||
): Promise<RpcResponse<{ opened: true }>>
|
||||
}
|
||||
@@ -1,28 +0,0 @@
|
||||
/**
|
||||
* skills domain zod schemas (names derived from map keys: skillListRequestSchema /
|
||||
* skillListValueSchema).
|
||||
*/
|
||||
|
||||
import { z } from 'zod'
|
||||
import type { RequestPayload, ResponseValue } from './rpc-map.ts'
|
||||
import type { Wire } from './rpc.schema.ts'
|
||||
import { sessionIdSchema } from './ids.schema.ts'
|
||||
import type { SkillEntry } from './skills.ts'
|
||||
|
||||
/** SkillEntry row of skill.list. */
|
||||
export const skillEntrySchema = z.object({
|
||||
name: z.string().min(1),
|
||||
description: z.string(),
|
||||
whenToUse: z.string().optional(),
|
||||
modelInvocable: z.boolean(),
|
||||
}) satisfies z.ZodType<Wire<SkillEntry>>
|
||||
|
||||
/** skill.list request payload. */
|
||||
export const skillListRequestSchema = z.object({
|
||||
sessionId: sessionIdSchema,
|
||||
}) satisfies z.ZodType<Wire<RequestPayload<'skill.list'>>>
|
||||
|
||||
/** skill.list response value. */
|
||||
export const skillListValueSchema = z.object({
|
||||
skills: z.array(skillEntrySchema),
|
||||
}) satisfies z.ZodType<Wire<ResponseValue<'skill.list'>>>
|
||||
@@ -1,33 +0,0 @@
|
||||
/**
|
||||
* skills domain contract: read-only skill catalog lookup addressed by session.
|
||||
* The session's header cwd resolves to the canonical project root host-side —
|
||||
* the client never submits a raw path, and skill lookup never creates or
|
||||
* resumes an Agent.
|
||||
*/
|
||||
|
||||
import type { SessionId } from '@deepseek-ai/dsh-session/types'
|
||||
import type { RpcRequest, RpcResponse } from './rpc.ts'
|
||||
|
||||
/** Skill catalog row (wire projection of the host SkillSummary; provider/source vocabulary stays host-side). */
|
||||
export interface SkillEntry {
|
||||
/** Kebab-case identifier the user references as `/name` in the composer. */
|
||||
readonly name: string
|
||||
/** Short routing description. */
|
||||
readonly description: string
|
||||
/** Optional extra routing guidance. */
|
||||
readonly whenToUse?: string
|
||||
/** False marks a user-only skill (`disable-model-invocation`): invocable here, absent from the model catalog. */
|
||||
readonly modelInvocable: boolean
|
||||
}
|
||||
|
||||
/**
|
||||
* Skill-domain unary methods (the map key skill.* of RpcMethodMap). Listing
|
||||
* is the domain's only RPC: invocation uses Session Controller's ordinary
|
||||
* prompt Remote. The host recognizes its leading `/name` token at the pre-step
|
||||
* boundary (`dsh-tool-skill` injects the rendered body there), so every client
|
||||
* shares one deterministic path with no dedicated invocation method.
|
||||
*/
|
||||
export interface SkillsApi {
|
||||
/** Lists the user-invocable skill catalog for the session's project. */
|
||||
list(request: RpcRequest<{ sessionId: SessionId }>): Promise<RpcResponse<{ skills: readonly SkillEntry[] }>>
|
||||
}
|
||||
@@ -12,17 +12,7 @@ import type { ClientRequest, RpcMessage, RpcResponse } from '../api/rpc.ts'
|
||||
import { RpcId } from '../api/rpc.ts'
|
||||
import type { Wire } from '../api/rpc.schema.ts'
|
||||
import { serverResponseSchema } from '../api/rpc.schema.ts'
|
||||
import {
|
||||
hostDescribeValueSchema, hostOpenPathValueSchema,
|
||||
} from '../api/host.schema.ts'
|
||||
import { skillListValueSchema } from '../api/skills.schema.ts'
|
||||
import {
|
||||
agentPresetOpenDocumentValueSchema,
|
||||
} from '../api/agent-presets.schema.ts'
|
||||
import {
|
||||
settingsOpenDocumentValueSchema,
|
||||
} from '../api/settings.schema.ts'
|
||||
import { llmDiscoverModelsValueSchema, llmModelsValueSchema, llmProvidersValueSchema } from '../api/llm.schema.ts'
|
||||
import { hostDescribeValueSchema } from '../api/host.schema.ts'
|
||||
|
||||
/**
|
||||
* Client consumption face of the contract (shape a): same domain tree as ApiProxy, but unary
|
||||
@@ -39,21 +29,6 @@ import { llmDiscoverModelsValueSchema, llmModelsValueSchema, llmProvidersValueSc
|
||||
export interface IApiClient {
|
||||
host: {
|
||||
describe(payload: RequestPayload<'host.describe'>, signal?: AbortSignal): Promise<RpcResponse<ResponseValue<'host.describe'>>>
|
||||
openPath(payload: RequestPayload<'host.openPath'>, signal?: AbortSignal): Promise<RpcResponse<ResponseValue<'host.openPath'>>>
|
||||
}
|
||||
skills: {
|
||||
list(payload: RequestPayload<'skill.list'>, signal?: AbortSignal): Promise<RpcResponse<ResponseValue<'skill.list'>>>
|
||||
}
|
||||
agentPresets: {
|
||||
openDocument(payload: RequestPayload<'agentPreset.openDocument'>, signal?: AbortSignal): Promise<RpcResponse<ResponseValue<'agentPreset.openDocument'>>>
|
||||
}
|
||||
settings: {
|
||||
openDocument(payload: RequestPayload<'settings.openDocument'>, signal?: AbortSignal): Promise<RpcResponse<ResponseValue<'settings.openDocument'>>>
|
||||
}
|
||||
llm: {
|
||||
providers(payload: RequestPayload<'llm.providers'>, signal?: AbortSignal): Promise<RpcResponse<ResponseValue<'llm.providers'>>>
|
||||
models(payload: RequestPayload<'llm.models'>, signal?: AbortSignal): Promise<RpcResponse<ResponseValue<'llm.models'>>>
|
||||
discoverModels(payload: RequestPayload<'llm.discoverModels'>, signal?: AbortSignal): Promise<RpcResponse<ResponseValue<'llm.discoverModels'>>>
|
||||
}
|
||||
}
|
||||
|
||||
@@ -63,13 +38,6 @@ export interface IApiClient {
|
||||
*/
|
||||
const UNARY_VALUE_SCHEMAS: { [K in keyof RpcMethodMap]: z.ZodType<Wire<ResponseValue<K>>> } = {
|
||||
'host.describe': hostDescribeValueSchema,
|
||||
'host.openPath': hostOpenPathValueSchema,
|
||||
'skill.list': skillListValueSchema,
|
||||
'agentPreset.openDocument': agentPresetOpenDocumentValueSchema,
|
||||
'settings.openDocument': settingsOpenDocumentValueSchema,
|
||||
'llm.providers': llmProvidersValueSchema,
|
||||
'llm.models': llmModelsValueSchema,
|
||||
'llm.discoverModels': llmDiscoverModelsValueSchema,
|
||||
}
|
||||
|
||||
/** Default timeout for bounded unary calls (rpc-compare 2026-07-19: a hung host must not leave callers pending forever). */
|
||||
@@ -195,30 +163,6 @@ export abstract class AbstractApiClient implements IApiClient {
|
||||
|
||||
readonly host: IApiClient['host'] = {
|
||||
describe: (payload, signal) => this.callUnary('host.describe', payload, signal),
|
||||
openPath: (payload, signal) => this.callUnary('host.openPath', payload, signal),
|
||||
}
|
||||
|
||||
readonly skills: IApiClient['skills'] = {
|
||||
list: (payload, signal) => this.callUnary('skill.list', payload, signal),
|
||||
}
|
||||
|
||||
// Annotated like every sibling, and load-bearing rather than cosmetic:
|
||||
// inferring this member inlines `AgentPresetEntry` into the emitted
|
||||
// declaration by the specifier TS picks — the host `index.ts` — which drags
|
||||
// the whole gateway, and with it the host `Context` merges, into every
|
||||
// Client program that imports this carrier.
|
||||
readonly agentPresets: IApiClient['agentPresets'] = {
|
||||
openDocument: (payload, signal) => this.callUnary('agentPreset.openDocument', payload, signal),
|
||||
}
|
||||
|
||||
readonly settings: IApiClient['settings'] = {
|
||||
openDocument: (payload, signal) => this.callUnary('settings.openDocument', payload, signal),
|
||||
}
|
||||
|
||||
readonly llm: IApiClient['llm'] = {
|
||||
providers: (payload, signal) => this.callUnary('llm.providers', payload, signal),
|
||||
models: (payload, signal) => this.callUnary('llm.models', payload, signal),
|
||||
discoverModels: (payload, signal) => this.callUnary('llm.discoverModels', payload, signal),
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@@ -14,17 +14,7 @@ import type { ClientRequest, RpcError, RpcRequest, RpcResponse, ServerResponse }
|
||||
import { RpcId } from '../api/rpc.ts'
|
||||
import type { Wire } from '../api/rpc.schema.ts'
|
||||
import { clientRequestSchema } from '../api/rpc.schema.ts'
|
||||
import {
|
||||
hostDescribeRequestSchema, hostOpenPathRequestSchema,
|
||||
} from '../api/host.schema.ts'
|
||||
import { skillListRequestSchema } from '../api/skills.schema.ts'
|
||||
import {
|
||||
agentPresetOpenDocumentRequestSchema,
|
||||
} from '../api/agent-presets.schema.ts'
|
||||
import {
|
||||
settingsOpenDocumentRequestSchema,
|
||||
} from '../api/settings.schema.ts'
|
||||
import { llmDiscoverModelsRequestSchema, llmModelsRequestSchema, llmProvidersRequestSchema } from '../api/llm.schema.ts'
|
||||
import { hostDescribeRequestSchema } from '../api/host.schema.ts'
|
||||
|
||||
/**
|
||||
* Unary dispatch table, keyed by (and compiler-locked to) RpcMethodMap: a map row without a
|
||||
@@ -44,13 +34,6 @@ type UnaryRoutes = {
|
||||
|
||||
const UNARY_ROUTES: UnaryRoutes = {
|
||||
'host.describe': { schema: hostDescribeRequestSchema, invoke: (api, r) => api.host.describe(r) },
|
||||
'host.openPath': { schema: hostOpenPathRequestSchema, invoke: (api, r, signal) => api.host.openPath(r, signal) },
|
||||
'skill.list': { schema: skillListRequestSchema, invoke: (api, r) => api.skills.list(r) },
|
||||
'agentPreset.openDocument': { schema: agentPresetOpenDocumentRequestSchema, invoke: (api, r, signal) => api.agentPresets.openDocument(r, signal) },
|
||||
'settings.openDocument': { schema: settingsOpenDocumentRequestSchema, invoke: (api, r, signal) => api.settings.openDocument(r, signal) },
|
||||
'llm.providers': { schema: llmProvidersRequestSchema, invoke: (api, r) => api.llm.providers(r) },
|
||||
'llm.models': { schema: llmModelsRequestSchema, invoke: (api, r) => api.llm.models(r) },
|
||||
'llm.discoverModels': { schema: llmDiscoverModelsRequestSchema, invoke: (api, r, signal) => api.llm.discoverModels(r, signal) },
|
||||
}
|
||||
|
||||
/** Route lookup that narrows an arbitrary path segment to a map key (single cast point for the string→key refinement). */
|
||||
|
||||
@@ -14,8 +14,6 @@
|
||||
import { Context, Service } from '@deepseek-ai/cordis'
|
||||
import z from '@deepseek-ai/schemastery'
|
||||
import type {} from '@deepseek-ai/dsh-agent-default-model'
|
||||
import type {} from '@deepseek-ai/dsh-api-session-controller'
|
||||
import type {} from '@deepseek-ai/dsh-host-directory-picker'
|
||||
import type { ApiProxy } from './api/index.ts'
|
||||
import { createApiProxy } from './api-proxy.ts'
|
||||
import {
|
||||
@@ -63,8 +61,7 @@ export interface Config {
|
||||
*/
|
||||
export class ApiProxyService extends Service implements ApiProxy {
|
||||
static inject = [
|
||||
'agentDefaultModel', 'agents', 'attachments', 'directoryPicker', 'llm', 'sessions', 'sessionQuery',
|
||||
'sessionController',
|
||||
'agentDefaultModel', 'agents', 'attachments', 'sessions', 'sessionQuery',
|
||||
]
|
||||
|
||||
static Config: z<Config> = z.object({
|
||||
@@ -74,10 +71,6 @@ export class ApiProxyService extends Service implements ApiProxy {
|
||||
})
|
||||
|
||||
readonly host: ApiProxy['host']
|
||||
readonly skills: ApiProxy['skills']
|
||||
readonly agentPresets: ApiProxy['agentPresets']
|
||||
readonly settings: ApiProxy['settings']
|
||||
readonly llm: ApiProxy['llm']
|
||||
readonly downloads: ApiProxy['downloads']
|
||||
|
||||
constructor(ctx: Context, config: Config) {
|
||||
@@ -91,10 +84,6 @@ export class ApiProxyService extends Service implements ApiProxy {
|
||||
: { sessionExportCompressionLevel: config.sessionExportCompressionLevel }),
|
||||
})
|
||||
this.host = api.host
|
||||
this.skills = api.skills
|
||||
this.agentPresets = api.agentPresets
|
||||
this.settings = api.settings
|
||||
this.llm = api.llm
|
||||
this.downloads = api.downloads
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,202 +0,0 @@
|
||||
/**
|
||||
* Cross-platform native path and text-document openers used by the local GUI
|
||||
* carrier.
|
||||
*
|
||||
* The default intent prefers the default browser for documents it renders when
|
||||
* the platform can name one, then falls back to the default application. WSL
|
||||
* translates every path for the Windows desktop instead of assuming a Linux
|
||||
* GUI. The text-editor intent never consults the browser.
|
||||
*/
|
||||
|
||||
import { release as osRelease } from 'node:os'
|
||||
import { extname } from 'node:path'
|
||||
import { runNativeCommand, type NativeCommandRunner } from '@deepseek-ai/dsh-native-command'
|
||||
|
||||
/** Testable command boundary; native implementations never invoke a shell. */
|
||||
export type PathOpenerRunner = NativeCommandRunner
|
||||
|
||||
/** Injectable platform facts for deterministic adapter tests. */
|
||||
export interface PathOpenerInternals {
|
||||
platform?: NodeJS.Platform
|
||||
/** Kernel release override used to distinguish WSL from desktop Linux. */
|
||||
osRelease?: string
|
||||
/** Environment used for WSL markers and the desktop Linux browser convention. */
|
||||
env?: NodeJS.ProcessEnv
|
||||
run?: PathOpenerRunner
|
||||
}
|
||||
|
||||
/** Documents a browser renders, as opposed to ones an editor merely edits. */
|
||||
const BROWSER_DOCUMENTS = new Set(['.html', '.htm', '.xhtml', '.svg'])
|
||||
|
||||
/**
|
||||
* The macOS bundle registered for `https` — the default browser, as
|
||||
* LaunchServices records it. The nested version dict is stripped first
|
||||
* because it carries its own `LSHandlerRoleAll`.
|
||||
*/
|
||||
function macBundleForHttps(plist: string): string | undefined {
|
||||
const stripped = plist.replace(/LSHandlerPreferredVersions\s*=\s*\{[^}]*\};/g, '')
|
||||
const block = /\{[^{}]*LSHandlerURLScheme\s*=\s*"?https"?;[^{}]*\}/.exec(stripped)?.[0]
|
||||
if (block === undefined) return undefined
|
||||
return /LSHandlerRoleAll\s*=\s*"?([\w.-]+)"?;/.exec(block)?.[1]
|
||||
}
|
||||
|
||||
/**
|
||||
* Open one browser-renderable document with the default browser.
|
||||
* @returns true when a browser took it; false when this platform cannot name
|
||||
* one, or naming it failed — the caller then uses the default application.
|
||||
*/
|
||||
async function openInBrowser(
|
||||
path: string, signal: AbortSignal, platform: NodeJS.Platform,
|
||||
run: PathOpenerRunner, env: NodeJS.ProcessEnv,
|
||||
): Promise<boolean> {
|
||||
if (platform === 'darwin') {
|
||||
let bundle: string | undefined
|
||||
try {
|
||||
const { stdout } = await run(
|
||||
'defaults', ['read', 'com.apple.LaunchServices/com.apple.launchservices.secure'], signal)
|
||||
bundle = macBundleForHttps(stdout)
|
||||
} catch {
|
||||
// No LaunchServices record (a fresh account never changed a default):
|
||||
// the content-type handler is then the system's own choice anyway.
|
||||
return false
|
||||
}
|
||||
if (bundle === undefined) return false
|
||||
await run('open', ['-b', bundle, path], signal)
|
||||
return true
|
||||
}
|
||||
if (platform === 'linux') {
|
||||
// $BROWSER is the portable convention; desktop-entry resolution through
|
||||
// xdg-settings needs a launcher this package has no business shipping.
|
||||
const browser = env.BROWSER
|
||||
if (browser === undefined || browser === '') return false
|
||||
await run(browser, [path], signal)
|
||||
return true
|
||||
}
|
||||
// Windows names no browser without reading the UserChoice registry, and its
|
||||
// .html association is the browser in the ordinary case.
|
||||
return false
|
||||
}
|
||||
|
||||
/** Native path-open intent; macOS distinguishes text editing from file association. */
|
||||
type PathOpenIntent = 'default' | 'text-editor'
|
||||
|
||||
/** PowerShell single-quoted literal (doubles embedded quotes). */
|
||||
function powershellLiteral(path: string): string {
|
||||
return `'${path.replace(/'/g, "''")}'`
|
||||
}
|
||||
|
||||
/** Whether one environment marker is set to a non-empty value. */
|
||||
function present(value: string | undefined): boolean {
|
||||
return value !== undefined && value !== ''
|
||||
}
|
||||
|
||||
/** Distinguish WSL from desktop Linux using its process and kernel markers. */
|
||||
function isWsl(internals: PathOpenerInternals): boolean {
|
||||
const env = internals.env ?? process.env
|
||||
if (present(env.WSL_DISTRO_NAME) || present(env.WSL_INTEROP)) return true
|
||||
return (internals.osRelease ?? osRelease()).toLowerCase().includes('microsoft')
|
||||
}
|
||||
|
||||
/** Open one Windows-resolvable path through its registered desktop application. */
|
||||
async function openWindowsPath(path: string, signal: AbortSignal, run: PathOpenerRunner): Promise<void> {
|
||||
await run('powershell.exe', [
|
||||
'-NoProfile',
|
||||
'-Command',
|
||||
`Invoke-Item -LiteralPath ${powershellLiteral(path)}`,
|
||||
], signal)
|
||||
}
|
||||
|
||||
/** Translate a WSL path before handing it to the Windows desktop. */
|
||||
async function openWslPath(path: string, signal: AbortSignal, run: PathOpenerRunner): Promise<void> {
|
||||
const translated = await run('wslpath', ['-w', path], signal)
|
||||
signal.throwIfAborted()
|
||||
const windowsPath = translated.stdout.replace(/[\r\n]+$/, '')
|
||||
if (windowsPath === '') throw new Error('wslpath returned no Windows path')
|
||||
await openWindowsPath(windowsPath, signal, run)
|
||||
}
|
||||
|
||||
/** Dispatch one shell-free platform command for the requested open intent. */
|
||||
async function openNativePathWithIntent(
|
||||
path: string,
|
||||
signal: AbortSignal,
|
||||
intent: PathOpenIntent,
|
||||
internals: PathOpenerInternals = {},
|
||||
): Promise<void> {
|
||||
const platform = internals.platform ?? process.platform
|
||||
const run = internals.run ?? runNativeCommand
|
||||
const env = internals.env ?? process.env
|
||||
const wsl = platform === 'linux' && isWsl(internals)
|
||||
|
||||
if (!wsl && intent === 'default' && BROWSER_DOCUMENTS.has(extname(path).toLowerCase())
|
||||
&& await openInBrowser(path, signal, platform, run, env)) return
|
||||
|
||||
if (platform === 'darwin') {
|
||||
await run('open', intent === 'text-editor' ? ['-t', path] : [path], signal)
|
||||
return
|
||||
}
|
||||
|
||||
if (platform === 'win32') {
|
||||
await openWindowsPath(path, signal, run)
|
||||
return
|
||||
}
|
||||
|
||||
if (platform === 'linux') {
|
||||
if (wsl) {
|
||||
await openWslPath(path, signal, run)
|
||||
return
|
||||
}
|
||||
await run('xdg-open', [path], signal)
|
||||
return
|
||||
}
|
||||
|
||||
throw new Error(`native path opener is unsupported on ${platform}`)
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether {@link openNativePath} plausibly reaches a desktop on this host.
|
||||
*
|
||||
* macOS and Windows always carry a desktop opener; Linux does when it is WSL
|
||||
* (the Windows desktop takes the path) or a display server is announced.
|
||||
* A headless or containerised Linux host answers false, which is what lets a
|
||||
* surface show a path as text instead of offering a button that would spawn
|
||||
* `xdg-open` into nothing.
|
||||
* @param internals - platform and environment seam for deterministic tests.
|
||||
* @returns true when handing a path to the native opener can work at all.
|
||||
*/
|
||||
export function canOpenNativePath(internals: PathOpenerInternals = {}): boolean {
|
||||
const platform = internals.platform ?? process.platform
|
||||
if (platform === 'darwin' || platform === 'win32') return true
|
||||
if (platform !== 'linux') return false
|
||||
const env = internals.env ?? process.env
|
||||
return isWsl(internals) || present(env.DISPLAY) || present(env.WAYLAND_DISPLAY)
|
||||
}
|
||||
|
||||
/**
|
||||
* Open a filesystem path with the operating system's default application, or
|
||||
* with the default browser when the path names a document a browser renders.
|
||||
* @param path - absolute or host-resolvable path (caller owns resolution).
|
||||
* @param signal - caller/connection lifetime; abort terminates the native command.
|
||||
* @param internals - Platform, environment, and runner hooks for deterministic tests.
|
||||
*/
|
||||
export function openNativePath(
|
||||
path: string,
|
||||
signal: AbortSignal,
|
||||
internals: PathOpenerInternals = {},
|
||||
): Promise<void> {
|
||||
return openNativePathWithIntent(path, signal, 'default', internals)
|
||||
}
|
||||
|
||||
/**
|
||||
* Open a text document for editing; macOS bypasses the file-type association
|
||||
* so a YAML association with a browser cannot consume the gesture.
|
||||
* @param path - absolute or host-resolvable text-document path.
|
||||
* @param signal - caller/connection lifetime; abort terminates the native command.
|
||||
* @param internals - Platform and runner hooks for deterministic tests.
|
||||
*/
|
||||
export function openNativeTextFile(
|
||||
path: string,
|
||||
signal: AbortSignal,
|
||||
internals: PathOpenerInternals = {},
|
||||
): Promise<void> {
|
||||
return openNativePathWithIntent(path, signal, 'text-editor', internals)
|
||||
}
|
||||
@@ -8,9 +8,6 @@
|
||||
"src"
|
||||
],
|
||||
"references": [
|
||||
{
|
||||
"path": "../../settings/settings"
|
||||
},
|
||||
{
|
||||
"path": "../../credentials/credentials"
|
||||
},
|
||||
@@ -29,39 +26,21 @@
|
||||
{
|
||||
"path": "../../attachment/attachment"
|
||||
},
|
||||
{
|
||||
"path": "../../llm/llm"
|
||||
},
|
||||
{
|
||||
"path": "../../core/agent"
|
||||
},
|
||||
{
|
||||
"path": "../../core/agent-default-model"
|
||||
},
|
||||
{
|
||||
"path": "../../preset/agent-presets"
|
||||
},
|
||||
{
|
||||
"path": "../../core/session"
|
||||
},
|
||||
{
|
||||
"path": "../../core/scope"
|
||||
},
|
||||
{
|
||||
"path": "../../session/session-persistence"
|
||||
},
|
||||
{
|
||||
"path": "../../session-query/session-query"
|
||||
},
|
||||
{
|
||||
"path": "../../skill/skill"
|
||||
},
|
||||
{
|
||||
"path": "../../interaction/commands"
|
||||
},
|
||||
{
|
||||
"path": "../directory-picker"
|
||||
},
|
||||
{
|
||||
"path": "../../runtime-diagnostics/invariants"
|
||||
},
|
||||
|
||||
Reference in New Issue
Block a user