mirror of
https://github.com/deepseek-ai/deepseek-harness.git
synced 2026-08-29 04:26:38 +00:00
refactor(api): expose remaining domain remotes
This commit is contained in:
@@ -85,15 +85,18 @@
|
||||
"@deepseek-ai/dsh-attachment": "workspace:^",
|
||||
"@deepseek-ai/dsh-brand": "workspace:^",
|
||||
"@deepseek-ai/dsh-client-connection": "workspace:^",
|
||||
"@deepseek-ai/dsh-file-reference": "workspace:^",
|
||||
"@deepseek-ai/dsh-invariants": "workspace:^",
|
||||
"@deepseek-ai/dsh-jobs": "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-projection": "workspace:^",
|
||||
"@deepseek-ai/dsh-session-projection-cache": "workspace:^",
|
||||
"@deepseek-ai/dsh-session-query": "workspace:^",
|
||||
"@deepseek-ai/dsh-skill": "workspace:^",
|
||||
"@deepseek-ai/dsh-session-title": "workspace:^",
|
||||
"@deepseek-ai/dsh-subagent": "workspace:^",
|
||||
"@deepseek-ai/dsh-typert-protocol": "workspace:^",
|
||||
@@ -116,9 +119,11 @@
|
||||
"@deepseek-ai/dsh-brand": "workspace:^",
|
||||
"@deepseek-ai/dsh-client-connection": "workspace:^",
|
||||
"@deepseek-ai/dsh-client-store": "workspace:^",
|
||||
"@deepseek-ai/dsh-file-reference": "workspace:^",
|
||||
"@deepseek-ai/dsh-invariants": "workspace:^",
|
||||
"@deepseek-ai/dsh-jobs": "workspace:^",
|
||||
"@deepseek-ai/dsh-llm": "workspace:^",
|
||||
"@deepseek-ai/dsh-native-command": "workspace:^",
|
||||
"@deepseek-ai/dsh-permission-presets": "workspace:^",
|
||||
"@deepseek-ai/dsh-scope": "workspace:^",
|
||||
"@deepseek-ai/dsh-session": "workspace:^",
|
||||
@@ -126,6 +131,7 @@
|
||||
"@deepseek-ai/dsh-session-projection": "workspace:^",
|
||||
"@deepseek-ai/dsh-session-projection-cache": "workspace:^",
|
||||
"@deepseek-ai/dsh-session-query": "workspace:^",
|
||||
"@deepseek-ai/dsh-skill": "workspace:^",
|
||||
"@deepseek-ai/dsh-session-title": "workspace:^",
|
||||
"@deepseek-ai/dsh-subagent": "workspace:^",
|
||||
"@deepseek-ai/dsh-typert-protocol": "workspace:^",
|
||||
|
||||
@@ -0,0 +1,42 @@
|
||||
/** Session Controller adapter for Agent-scoped file-reference discovery. */
|
||||
|
||||
import type { Context } from '@deepseek-ai/cordis'
|
||||
import type { Agent } from '@deepseek-ai/dsh-agent'
|
||||
import type {} from '@deepseek-ai/dsh-file-reference'
|
||||
import type { FileReferenceCandidate } from '@deepseek-ai/dsh-file-reference/types'
|
||||
import { Remote, TypertRemoteService } from '@deepseek-ai/dsh-typert-protocol'
|
||||
|
||||
declare module '@deepseek-ai/cordis' {
|
||||
interface Context {
|
||||
/** Host owner of the `fileReferences` Remote namespace. */
|
||||
sessionFileReferences: SessionFileReferences
|
||||
}
|
||||
}
|
||||
|
||||
/** Host Remote adapter over the composed file-reference provider. */
|
||||
export class SessionFileReferences extends TypertRemoteService {
|
||||
static inject = ['fileReferences', 'typert']
|
||||
|
||||
/** @param ctx - Host context carrying the selected file-reference provider. */
|
||||
constructor(ctx: Context) {
|
||||
super(ctx, 'sessionFileReferences', { namespace: 'fileReferences' })
|
||||
}
|
||||
|
||||
/**
|
||||
* List file and directory candidates for one Agent's working directory.
|
||||
* @param agent - target Agent resolved from the Session identity on the wire.
|
||||
* @param query - path text following `@` or `@"`.
|
||||
* @param signal - caller cancellation.
|
||||
* @returns deterministic path-only candidates from the composed provider.
|
||||
*/
|
||||
@Remote
|
||||
list(
|
||||
agent: Agent,
|
||||
query: string,
|
||||
signal: AbortSignal,
|
||||
): Promise<FileReferenceCandidate[]> {
|
||||
return this.ctx.fileReferences.list(agent, query, signal)
|
||||
}
|
||||
}
|
||||
|
||||
export default SessionFileReferences
|
||||
@@ -3,10 +3,13 @@
|
||||
import { Context } from '@deepseek-ai/cordis'
|
||||
import z from '@deepseek-ai/schemastery'
|
||||
import { errorChain } from '@deepseek-ai/dsh-llm'
|
||||
import { openNativePath } from '@deepseek-ai/dsh-native-command'
|
||||
import type { SessionEvent, SessionHeader, SessionId } from '@deepseek-ai/dsh-session'
|
||||
import type { SessionObservation } from '@deepseek-ai/dsh-session-query'
|
||||
import { Remote, TypertRemoteService } from '@deepseek-ai/dsh-typert-protocol'
|
||||
import { Remote, TypertRemoteFailure, TypertRemoteService } from '@deepseek-ai/dsh-typert-protocol'
|
||||
import { resolveWorkspacePath } from '@deepseek-ai/dsh-util-workspace-path'
|
||||
import {
|
||||
ApiSessionNotFound,
|
||||
ApiSessionAgentController,
|
||||
inspectApiSession,
|
||||
type ApiSessionAgentResult,
|
||||
@@ -14,9 +17,13 @@ import {
|
||||
import { SessionCommandController } from './commands.ts'
|
||||
import { SessionControlController } from './control.ts'
|
||||
import { SessionHistoryController } from './history.ts'
|
||||
import { SessionFileReferences } from './file-references.ts'
|
||||
import { ApiSessionList, DEFAULT_COLD_BLANK_PROBE_MAX_BYTES } from './list.ts'
|
||||
import { buildModelCatalog } from './catalog.ts'
|
||||
import { installModelSelectionProjection } from './model-selection-projection.ts'
|
||||
import { SessionSkillCatalog } from './skill-catalog.ts'
|
||||
import type {
|
||||
ModelCatalog,
|
||||
SessionAttachmentRequest,
|
||||
SessionAttachmentValue,
|
||||
SessionCancelRequest,
|
||||
@@ -30,6 +37,8 @@ import type {
|
||||
SessionForkValue,
|
||||
SessionListRequest,
|
||||
SessionListValue,
|
||||
SessionOpenWorkspacePathRequest,
|
||||
SessionOpenWorkspacePathValue,
|
||||
SessionPage,
|
||||
SessionPageRequest,
|
||||
SessionPromptRequest,
|
||||
@@ -46,6 +55,8 @@ import type {
|
||||
|
||||
export type * from './types.ts'
|
||||
export { ApiSessionNotFound } from './agent.ts'
|
||||
export { SessionFileReferences } from './file-references.ts'
|
||||
export { SessionSkillCatalog } from './skill-catalog.ts'
|
||||
|
||||
declare module '@deepseek-ai/cordis' {
|
||||
interface Context {
|
||||
@@ -60,6 +71,12 @@ export interface Config {
|
||||
readonly coldBlankProbeMaxBytes?: number
|
||||
}
|
||||
|
||||
/** Host integrations replaceable by direct unit tests. */
|
||||
export interface SessionControllerInternals {
|
||||
/** Native default-application handoff. */
|
||||
readonly openPath?: (path: string, signal: AbortSignal) => Promise<void>
|
||||
}
|
||||
|
||||
/** Host service backing the generated `ctx.remote.session` namespace. */
|
||||
export class SessionController extends TypertRemoteService {
|
||||
static inject = [
|
||||
@@ -83,13 +100,14 @@ export class SessionController extends TypertRemoteService {
|
||||
private readonly controlState: SessionControlController
|
||||
private readonly history: SessionHistoryController
|
||||
private readonly listState: ApiSessionList
|
||||
private readonly openPath: (path: string, signal: AbortSignal) => Promise<void>
|
||||
private readonly promotions = new Set<Promise<void>>()
|
||||
|
||||
/**
|
||||
* @param ctx - Host context containing the Session capability assembly.
|
||||
* @param config - cold-list observation policy.
|
||||
*/
|
||||
constructor(ctx: Context, config: Config) {
|
||||
constructor(ctx: Context, config: Config, internals: SessionControllerInternals = {}) {
|
||||
super(ctx, 'sessionController', { namespace: 'session' })
|
||||
installModelSelectionProjection(ctx)
|
||||
this.agents = new ApiSessionAgentController(ctx)
|
||||
@@ -105,6 +123,9 @@ export class SessionController extends TypertRemoteService {
|
||||
ctx,
|
||||
config.coldBlankProbeMaxBytes ?? DEFAULT_COLD_BLANK_PROBE_MAX_BYTES,
|
||||
)
|
||||
this.openPath = internals.openPath ?? openNativePath
|
||||
ctx.plugin(SessionFileReferences)
|
||||
ctx.plugin(SessionSkillCatalog)
|
||||
|
||||
ctx.on('session/created', (session) => {
|
||||
ctx.emit('api-session/added', this.listState.summaryFor(session))
|
||||
@@ -214,6 +235,74 @@ export class SessionController extends TypertRemoteService {
|
||||
return this.commands.selectModel(request)
|
||||
}
|
||||
|
||||
/**
|
||||
* Describe every currently routable model for Host-generation selectors.
|
||||
* @returns provider-grouped models, the deployment default, and isolated provider failures.
|
||||
*/
|
||||
@Remote('modelCatalog')
|
||||
modelCatalog(): Promise<ModelCatalog> {
|
||||
return buildModelCatalog(this.ctx)
|
||||
}
|
||||
|
||||
/**
|
||||
* Open a path resolved against one Session's workspace on the Host desktop.
|
||||
* @param request - Session identity and absolute or workspace-relative path.
|
||||
* @param signal - caller lifetime; abort terminates inspection or the native command.
|
||||
* @returns confirmation after the native opener accepts the path.
|
||||
* @throws TypertRemoteFailure when the request is invalid, the Session is missing, or the opener fails.
|
||||
*/
|
||||
@Remote('openWorkspacePath')
|
||||
async openWorkspacePath(
|
||||
request: SessionOpenWorkspacePathRequest,
|
||||
signal: AbortSignal,
|
||||
): Promise<SessionOpenWorkspacePathValue> {
|
||||
if (request.path.length === 0) {
|
||||
throw new TypertRemoteFailure({
|
||||
code: 'bad-request',
|
||||
message: 'session.openWorkspacePath requires a non-empty path',
|
||||
details: {},
|
||||
})
|
||||
}
|
||||
signal.throwIfAborted()
|
||||
let cwd: string | undefined
|
||||
try {
|
||||
cwd = (await this.inspect(request.sessionId, signal)).meta.cwd
|
||||
} catch (error: unknown) {
|
||||
if (signal.aborted) {
|
||||
throw new TypertRemoteFailure({
|
||||
code: 'cancelled', message: 'path open was aborted', details: {},
|
||||
})
|
||||
}
|
||||
if (error instanceof ApiSessionNotFound) {
|
||||
throw new TypertRemoteFailure({
|
||||
code: 'session-not-found',
|
||||
message: error.message,
|
||||
details: { sessionId: request.sessionId },
|
||||
})
|
||||
}
|
||||
throw new TypertRemoteFailure({
|
||||
code: 'internal',
|
||||
message: `session "${request.sessionId}" could not be inspected: ${String(error)}`,
|
||||
details: {},
|
||||
})
|
||||
}
|
||||
try {
|
||||
await this.openPath(resolveWorkspacePath(cwd, request.path), signal)
|
||||
return { opened: true }
|
||||
} catch (error: unknown) {
|
||||
if (signal.aborted) {
|
||||
throw new TypertRemoteFailure({
|
||||
code: 'cancelled', message: 'path open was aborted', details: {},
|
||||
})
|
||||
}
|
||||
throw new TypertRemoteFailure({
|
||||
code: 'internal',
|
||||
message: `path open failed: ${error instanceof Error ? error.message : String(error)}`,
|
||||
details: {},
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Rename one Session after explicitly resuming it.
|
||||
* @param request - Session identity and proposed title.
|
||||
@@ -310,5 +399,5 @@ export class SessionController extends TypertRemoteService {
|
||||
|
||||
}
|
||||
|
||||
export { buildModelCatalog } from './catalog.ts'
|
||||
export { buildModelCatalog }
|
||||
export default SessionController
|
||||
|
||||
@@ -0,0 +1,120 @@
|
||||
/** Session-addressed, cold-readable skill catalog Remote. */
|
||||
|
||||
import type { Context } from '@deepseek-ai/cordis'
|
||||
import type {} from '@deepseek-ai/dsh-agent-presets/types'
|
||||
import type { SessionId } from '@deepseek-ai/dsh-session'
|
||||
import { SessionQueryError } from '@deepseek-ai/dsh-session-query'
|
||||
import { isUserInvocable } from '@deepseek-ai/dsh-skill'
|
||||
import type { ScopeKey } from '@deepseek-ai/dsh-scope'
|
||||
import { Remote, TypertRemoteFailure, TypertRemoteService } from '@deepseek-ai/dsh-typert-protocol'
|
||||
import type { SkillListRequest, SkillListValue } from './types.ts'
|
||||
|
||||
declare module '@deepseek-ai/cordis' {
|
||||
interface Context {
|
||||
/** Host owner of the Session-addressed `skills` Remote namespace. */
|
||||
sessionSkillCatalog: SessionSkillCatalog
|
||||
}
|
||||
}
|
||||
|
||||
/** Host service backing `ctx.remote.skills` without activating a cold Agent. */
|
||||
export class SessionSkillCatalog extends TypertRemoteService {
|
||||
static inject = ['agents', 'sessionQuery', 'typert']
|
||||
|
||||
/** @param ctx - Host context carrying Session reads and optional skill/preset services. */
|
||||
constructor(ctx: Context) {
|
||||
super(ctx, 'sessionSkillCatalog', { namespace: 'skills' })
|
||||
}
|
||||
|
||||
/**
|
||||
* List the user-invocable skills visible to one Session composition.
|
||||
* @param request - Session identity whose cwd and preset select the catalog view.
|
||||
* @param signal - caller lifetime carried by the Remote transport; admitted catalog reads retain their existing completion semantics.
|
||||
* @returns user-invocable skill metadata without loading skill bodies.
|
||||
* @throws TypertRemoteFailure when the Session cannot be inspected or no registry can serve it.
|
||||
*/
|
||||
@Remote
|
||||
async list(request: SkillListRequest, signal: AbortSignal): Promise<SkillListValue> {
|
||||
void signal
|
||||
const { sessionId } = request
|
||||
let cwd: string | undefined
|
||||
let agentPreset: string | undefined
|
||||
try {
|
||||
using observation = await this.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') {
|
||||
throw failure(
|
||||
'session-not-found',
|
||||
`session "${sessionId}" not found`,
|
||||
{ sessionId },
|
||||
)
|
||||
}
|
||||
throw failure(
|
||||
'internal',
|
||||
`session "${sessionId}" could not be inspected: ${String(error)}`,
|
||||
)
|
||||
}
|
||||
if (cwd === undefined) {
|
||||
throw failure('internal', `session "${sessionId}" has no project cwd`)
|
||||
}
|
||||
|
||||
const live = this.ctx.agents.get(sessionId)
|
||||
const presets = this.ctx.get('agentPresets')
|
||||
const scoped = live === undefined ? undefined : presets?.serviceFor(live, 'skills')
|
||||
const skillRegistry = scoped ?? this.ctx.get('skills')
|
||||
if (skillRegistry === undefined) {
|
||||
throw failure(
|
||||
'internal',
|
||||
'skill registry is absent: neither this session\'s agent preset nor the host composition mounts @deepseek-ai/dsh-skill',
|
||||
)
|
||||
}
|
||||
|
||||
const scope = await this.scopeFor(sessionId, agentPreset)
|
||||
try {
|
||||
const skills = (await skillRegistry.list({ cwd, scope })).filter(isUserInvocable)
|
||||
return {
|
||||
skills: skills.map(skill => ({
|
||||
name: skill.name,
|
||||
description: skill.description,
|
||||
...skill.whenToUse === undefined ? {} : { whenToUse: skill.whenToUse },
|
||||
modelInvocable: skill.invocation.modelInvocable,
|
||||
})),
|
||||
}
|
||||
} catch (error: unknown) {
|
||||
throw failure('internal', `skill listing failed: ${String(error)}`)
|
||||
}
|
||||
}
|
||||
|
||||
/** Resolve a live or standing preset scope without creating an Agent. */
|
||||
private async scopeFor(
|
||||
sessionId: SessionId,
|
||||
agentPreset: string | undefined,
|
||||
): Promise<ScopeKey | undefined> {
|
||||
const live = this.ctx.agents.get(sessionId)
|
||||
if (live !== undefined) return live
|
||||
const presets = this.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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Build one stable Remote failure with optional typed details. */
|
||||
function failure(
|
||||
code: 'session-not-found' | 'internal',
|
||||
message: string,
|
||||
details: { readonly sessionId: SessionId } | Record<never, never> = {},
|
||||
): TypertRemoteFailure {
|
||||
return new TypertRemoteFailure({ code, message, details })
|
||||
}
|
||||
|
||||
export default SessionSkillCatalog
|
||||
@@ -223,6 +223,28 @@ export type SessionError = {
|
||||
}
|
||||
}[keyof SessionErrorDetailsMap]
|
||||
|
||||
/** Session-addressed request for the human-invocable skill catalog. */
|
||||
export interface SkillListRequest {
|
||||
readonly sessionId: SessionId
|
||||
}
|
||||
|
||||
/** One skill available to the Session's human-facing composer. */
|
||||
export interface SkillEntry {
|
||||
/** Kebab-case identifier referenced as `/name`. */
|
||||
readonly name: string
|
||||
/** Short routing description. */
|
||||
readonly description: string
|
||||
/** Optional extra routing guidance. */
|
||||
readonly whenToUse?: string
|
||||
/** Whether the same skill is also advertised to the model. */
|
||||
readonly modelInvocable: boolean
|
||||
}
|
||||
|
||||
/** Human-invocable skills visible through one Session's composition. */
|
||||
export interface SkillListValue {
|
||||
readonly skills: readonly SkillEntry[]
|
||||
}
|
||||
|
||||
/** Session list request. */
|
||||
export interface SessionListRequest {
|
||||
readonly cursor?: string
|
||||
@@ -340,6 +362,18 @@ export interface SessionCancelValue {
|
||||
readonly accepted: true
|
||||
}
|
||||
|
||||
/** Session-addressed request to open one workspace path on the Host desktop. */
|
||||
export interface SessionOpenWorkspacePathRequest {
|
||||
readonly sessionId: SessionId
|
||||
/** Absolute or Session-workspace-relative path. */
|
||||
readonly path: string
|
||||
}
|
||||
|
||||
/** Confirmation that the Host handed a workspace path to its native opener. */
|
||||
export interface SessionOpenWorkspacePathValue {
|
||||
readonly opened: true
|
||||
}
|
||||
|
||||
/** Client-minted prompt identity used to reconcile optimistic and durable messages. */
|
||||
export type SessionRequestId = Branded<'session-request-id'>
|
||||
|
||||
|
||||
@@ -14,9 +14,11 @@
|
||||
"src/catalog.ts",
|
||||
"src/commands.ts",
|
||||
"src/control.ts",
|
||||
"src/file-references.ts",
|
||||
"src/history.ts",
|
||||
"src/list.ts",
|
||||
"src/model-selection-projection.ts"
|
||||
"src/model-selection-projection.ts",
|
||||
"src/skill-catalog.ts"
|
||||
],
|
||||
"references": [
|
||||
{ "path": "../../../vendor/cordis" },
|
||||
@@ -25,10 +27,12 @@
|
||||
{ "path": "../../core/agent-default-model" },
|
||||
{ "path": "../../core/scope" },
|
||||
{ "path": "../../core/session" },
|
||||
{ "path": "../../context/file-reference" },
|
||||
{ "path": "../../attachment/attachment" },
|
||||
{ "path": "../../interaction/permission-presets" },
|
||||
{ "path": "../../jobs/jobs" },
|
||||
{ "path": "../../llm/llm" },
|
||||
{ "path": "../../util/native-command" },
|
||||
{ "path": "../../preset/agent-presets" },
|
||||
{ "path": "../../runtime-diagnostics/invariants" },
|
||||
{ "path": "../../session/session-persistence" },
|
||||
@@ -36,9 +40,11 @@
|
||||
{ "path": "../../session/session-projection-cache" },
|
||||
{ "path": "../../session/session-title" },
|
||||
{ "path": "../../session-query/session-query" },
|
||||
{ "path": "../../skill/skill" },
|
||||
{ "path": "../../subagent/subagent" },
|
||||
{ "path": "../../typert/protocol" },
|
||||
{ "path": "../../typert/registry" },
|
||||
{ "path": "../../util/workspace-path" },
|
||||
{ "path": "../../workspace/workspace" }
|
||||
]
|
||||
}
|
||||
|
||||
@@ -49,22 +49,28 @@
|
||||
],
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@deepseek-ai/schemastery": "workspace:^",
|
||||
"zod": "^4.4.3"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@deepseek-ai/cordis": "workspace:^",
|
||||
"@deepseek-ai/dsh-agent-presets": "workspace:^",
|
||||
"@deepseek-ai/dsh-credentials": "workspace:^",
|
||||
"@deepseek-ai/dsh-invariants": "workspace:^",
|
||||
"@deepseek-ai/dsh-native-command": "workspace:^",
|
||||
"@deepseek-ai/dsh-session": "workspace:^",
|
||||
"@deepseek-ai/dsh-settings": "workspace:^",
|
||||
"@deepseek-ai/dsh-typert-protocol": "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-native-command": "workspace:^",
|
||||
"@deepseek-ai/dsh-session": "workspace:^",
|
||||
"@deepseek-ai/dsh-settings": "workspace:^",
|
||||
"@deepseek-ai/dsh-typert-protocol": "workspace:^"
|
||||
"@deepseek-ai/dsh-typert-protocol": "workspace:^",
|
||||
"@deepseek-ai/schemastery": "workspace:^"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7,7 +7,20 @@
|
||||
* @module @deepseek-ai/dsh-api-settings-controller
|
||||
*/
|
||||
|
||||
import { dirname } from 'node:path'
|
||||
import { Context } from '@deepseek-ai/cordis'
|
||||
import Schema from '@deepseek-ai/schemastery'
|
||||
import {
|
||||
InvalidPresetIdError,
|
||||
PresetExistsError,
|
||||
PresetNotWritableError,
|
||||
UnknownPresetError,
|
||||
} from '@deepseek-ai/dsh-agent-presets'
|
||||
import {
|
||||
canOpenNativePath,
|
||||
openNativePath,
|
||||
openNativeTextFile,
|
||||
} from '@deepseek-ai/dsh-native-command'
|
||||
import { SettingsConflictError, settingsNamespace } from '@deepseek-ai/dsh-settings'
|
||||
import type { SettingsDescriptor, SettingsPathOp, SettingsProvider } from '@deepseek-ai/dsh-settings'
|
||||
import type {
|
||||
@@ -17,12 +30,26 @@ import type { JsonValue } from '@deepseek-ai/dsh-session/types'
|
||||
import { Remote, TypertRemoteFailure, TypertRemoteService } from '@deepseek-ai/dsh-typert-protocol'
|
||||
import { z } from 'zod'
|
||||
import { CredentialsController } from './credentials.ts'
|
||||
import type { AgentPresetDirectoryOpenValue, SettingsDocumentOpenValue } from './types.ts'
|
||||
|
||||
export { CredentialsController } from './credentials.ts'
|
||||
export type * from './types.ts'
|
||||
|
||||
const settingsNamespaceRequestSchema = z.object({ ns: z.string().min(1) })
|
||||
|
||||
/** Native document-opening policy. */
|
||||
export interface Config {
|
||||
/** Override platform desktop-opener detection. */
|
||||
readonly nativeOpen?: boolean
|
||||
}
|
||||
|
||||
/** Host integrations replaceable by direct unit tests. */
|
||||
export interface SettingsControllerInternals {
|
||||
readonly openPath?: (path: string, signal: AbortSignal) => Promise<void>
|
||||
readonly openTextFile?: (path: string, signal: AbortSignal) => Promise<void>
|
||||
readonly canOpenPath?: () => boolean
|
||||
}
|
||||
|
||||
/**
|
||||
* Project one redacted descriptor onto its wire view, field by field. The
|
||||
* Gateway returns a business result without decoding it, so a provider whose
|
||||
@@ -59,14 +86,24 @@ declare module '@deepseek-ai/cordis' {
|
||||
* `settings-conflict` or `settings-rejected` with the service's message.
|
||||
*/
|
||||
export class SettingsController extends TypertRemoteService {
|
||||
static Config: Schema<Config> = Schema.object({ nativeOpen: Schema.boolean() })
|
||||
|
||||
private readonly openPath: (path: string, signal: AbortSignal) => Promise<void>
|
||||
private readonly openTextFile: (path: string, signal: AbortSignal) => Promise<void>
|
||||
private readonly canOpenPath: () => boolean
|
||||
|
||||
/**
|
||||
* Register the settings namespace and mount the credentials namespace beside
|
||||
* it. Both namespaces stay registered when a provider is absent so calls can
|
||||
* return the configuration API's actionable missing-provider diagnostic.
|
||||
* @param ctx - Host context where settings and credential providers may be mounted.
|
||||
*/
|
||||
constructor(ctx: Context) {
|
||||
constructor(ctx: Context, config: Config = {}, internals: SettingsControllerInternals = {}) {
|
||||
super(ctx, 'settingsController', { namespace: 'settings' })
|
||||
this.openPath = internals.openPath ?? openNativePath
|
||||
this.openTextFile = internals.openTextFile ?? openNativeTextFile
|
||||
this.canOpenPath = internals.canOpenPath
|
||||
?? (() => config.nativeOpen ?? (internals.openPath !== undefined || canOpenNativePath()))
|
||||
ctx.plugin(CredentialsController)
|
||||
}
|
||||
|
||||
@@ -139,6 +176,81 @@ export class SettingsController extends TypertRemoteService {
|
||||
return this.write(ns, 'mutate', ops, expectedRevision)
|
||||
}
|
||||
|
||||
/**
|
||||
* Materialize the provider-owned settings document and open it in a native text editor.
|
||||
* @param signal - caller lifetime; abort terminates preparation or the native command.
|
||||
* @returns confirmation after the native opener accepts the document.
|
||||
* @throws TypertRemoteFailure when no document exists, preparation fails, or opening fails.
|
||||
*/
|
||||
@Remote
|
||||
async openSettingsDocument(signal: AbortSignal): Promise<SettingsDocumentOpenValue> {
|
||||
const settings = this.provider()
|
||||
if (signal.aborted) throw cancelled('settings document open was aborted')
|
||||
let path: string | undefined
|
||||
try {
|
||||
path = await settings.prepareDocument()
|
||||
} catch (error: unknown) {
|
||||
if (signal.aborted) throw cancelled('settings document preparation was aborted')
|
||||
throw internal(`settings document preparation failed: ${messageOf(error)}`)
|
||||
}
|
||||
if (path === undefined) {
|
||||
throw internal('settings provider has no local document to open')
|
||||
}
|
||||
if (signal.aborted) throw cancelled('settings document open was aborted')
|
||||
try {
|
||||
await this.openTextFile(path, signal)
|
||||
return { opened: true }
|
||||
} catch (error: unknown) {
|
||||
if (signal.aborted) throw cancelled('settings document open was aborted')
|
||||
throw internal(`path open failed: ${messageOf(error)}`)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Open one user-authored Agent preset directory or return its path when no native opener exists.
|
||||
* @param agentPreset - preset id resolved against Host-owned roots.
|
||||
* @param signal - caller lifetime; abort terminates the native command.
|
||||
* @returns an opened confirmation or the resolved directory for text display.
|
||||
* @throws TypertRemoteFailure when the preset is missing, read-only, invalid, or cannot be opened.
|
||||
*/
|
||||
@Remote
|
||||
async openAgentPresetDirectory(
|
||||
agentPreset: string,
|
||||
signal: AbortSignal,
|
||||
): Promise<AgentPresetDirectoryOpenValue> {
|
||||
if (agentPreset.length === 0) {
|
||||
throw new TypertRemoteFailure({
|
||||
code: 'bad-request', message: 'agent preset id must not be empty', details: {},
|
||||
})
|
||||
}
|
||||
const presets = this.ctx.get('agentPresets')
|
||||
if (presets === undefined) {
|
||||
throw new TypertRemoteFailure({
|
||||
code: 'agent-preset-not-found',
|
||||
message: 'this deployment composes no agent presets',
|
||||
details: { agentPreset, available: [] },
|
||||
})
|
||||
}
|
||||
let directory: string
|
||||
try {
|
||||
const preset = await presets.resolve(agentPreset)
|
||||
if (preset.trust !== 'user') {
|
||||
throw new PresetNotWritableError(preset.id, 'it ships with the deployment')
|
||||
}
|
||||
directory = dirname(preset.path)
|
||||
} catch (error: unknown) {
|
||||
throw presetFailure(agentPreset, error)
|
||||
}
|
||||
if (!this.canOpenPath()) return { opened: false, path: directory }
|
||||
try {
|
||||
await this.openPath(directory, signal)
|
||||
return { opened: true }
|
||||
} catch (error: unknown) {
|
||||
if (signal.aborted) throw cancelled('path open was aborted')
|
||||
throw internal(`path open failed: ${messageOf(error)}`)
|
||||
}
|
||||
}
|
||||
|
||||
private async write(
|
||||
ns: string,
|
||||
mode: 'update' | 'replace' | 'mutate',
|
||||
@@ -196,6 +308,44 @@ export class SettingsController extends TypertRemoteService {
|
||||
}
|
||||
}
|
||||
|
||||
function messageOf(error: unknown): string {
|
||||
return error instanceof Error ? error.message : String(error)
|
||||
}
|
||||
|
||||
function internal(message: string): TypertRemoteFailure {
|
||||
return new TypertRemoteFailure({ code: 'internal', message, details: {} })
|
||||
}
|
||||
|
||||
function cancelled(message: string): TypertRemoteFailure {
|
||||
return new TypertRemoteFailure({ code: 'cancelled', message, details: {} })
|
||||
}
|
||||
|
||||
function presetFailure(agentPreset: string, error: unknown): TypertRemoteFailure {
|
||||
if (error instanceof UnknownPresetError) {
|
||||
return new TypertRemoteFailure({
|
||||
code: 'agent-preset-not-found',
|
||||
message: error.message,
|
||||
details: { agentPreset: error.presetId, available: [...error.available] },
|
||||
})
|
||||
}
|
||||
if (error instanceof PresetNotWritableError) {
|
||||
return new TypertRemoteFailure({
|
||||
code: 'agent-preset-read-only',
|
||||
message: error.message,
|
||||
details: { agentPreset, reason: error.message },
|
||||
})
|
||||
}
|
||||
if (error instanceof InvalidPresetIdError || error instanceof PresetExistsError) {
|
||||
return new TypertRemoteFailure({
|
||||
code: 'agent-preset-invalid',
|
||||
message: error.message,
|
||||
details: { agentPreset, reason: error.message },
|
||||
})
|
||||
}
|
||||
if (error instanceof TypertRemoteFailure) return error
|
||||
return internal(`agent preset "${agentPreset}": ${String(error)}`)
|
||||
}
|
||||
|
||||
/**
|
||||
* Classify one seam refusal. A stale writer is its own outcome, not a malformed
|
||||
* request: the client must re-read and re-apply rather than treat the write as
|
||||
|
||||
@@ -30,6 +30,16 @@ export type SettingsError = {
|
||||
}
|
||||
}[keyof SettingsErrorDetailsMap]
|
||||
|
||||
/** Confirmation that the settings document was handed to the native editor. */
|
||||
export interface SettingsDocumentOpenValue {
|
||||
readonly opened: true
|
||||
}
|
||||
|
||||
/** Result of opening or revealing one locally authored Agent preset directory. */
|
||||
export type AgentPresetDirectoryOpenValue =
|
||||
| { readonly opened: true }
|
||||
| { readonly opened: false; readonly path: string }
|
||||
|
||||
/** Stable credential failure details returned by the `credentials` namespace. */
|
||||
export interface CredentialErrorDetailsMap {
|
||||
/**
|
||||
|
||||
@@ -11,6 +11,12 @@
|
||||
{
|
||||
"path": "../../../vendor/cordis"
|
||||
},
|
||||
{
|
||||
"path": "../../../vendor/schemastery"
|
||||
},
|
||||
{
|
||||
"path": "../../preset/agent-presets"
|
||||
},
|
||||
{
|
||||
"path": "../../credentials/credentials"
|
||||
},
|
||||
@@ -20,6 +26,9 @@
|
||||
{
|
||||
"path": "../../runtime-diagnostics/invariants"
|
||||
},
|
||||
{
|
||||
"path": "../../util/native-command"
|
||||
},
|
||||
{
|
||||
"path": "../../settings/settings"
|
||||
},
|
||||
|
||||
@@ -30,14 +30,6 @@
|
||||
"types": "./lib/types/types.d.ts",
|
||||
"default": "./lib/types/types.js"
|
||||
},
|
||||
"./typert": {
|
||||
"types": "./lib/typert.host.d.ts",
|
||||
"default": "./lib/typert.host.js"
|
||||
},
|
||||
"./remote": {
|
||||
"types": "./lib/typert.remote-client.d.ts",
|
||||
"default": "./lib/typert.remote-client.js"
|
||||
},
|
||||
"./src/*": "./src/*",
|
||||
"./package.json": "./package.json"
|
||||
},
|
||||
@@ -45,23 +37,17 @@
|
||||
"lib/index.js",
|
||||
"lib/invariant.js",
|
||||
"lib/types/**/*.js",
|
||||
"lib/types/**/*.d.ts",
|
||||
"lib/typert.host.js",
|
||||
"lib/typert.host.d.ts",
|
||||
"lib/typert.remote-client.js",
|
||||
"lib/typert.remote-client.d.ts"
|
||||
"lib/types/**/*.d.ts"
|
||||
],
|
||||
"license": "MIT",
|
||||
"peerDependencies": {
|
||||
"@deepseek-ai/dsh-agent": "workspace:^",
|
||||
"@deepseek-ai/dsh-invariants": "workspace:^",
|
||||
"@deepseek-ai/dsh-typert-protocol": "workspace:^",
|
||||
"@deepseek-ai/cordis": "workspace:^"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@deepseek-ai/dsh-agent": "workspace:^",
|
||||
"@deepseek-ai/dsh-invariants": "workspace:^",
|
||||
"@deepseek-ai/dsh-typert-protocol": "workspace:^",
|
||||
"@deepseek-ai/cordis": "workspace:^"
|
||||
},
|
||||
"dependencies": {
|
||||
|
||||
@@ -4,9 +4,8 @@
|
||||
* @module @deepseek-ai/dsh-file-reference
|
||||
*/
|
||||
|
||||
import type { Context } from '@deepseek-ai/cordis'
|
||||
import { Service, type Context } from '@deepseek-ai/cordis'
|
||||
import type { Agent } from '@deepseek-ai/dsh-agent'
|
||||
import { Remote, TypertRemoteService } from '@deepseek-ai/dsh-typert-protocol'
|
||||
|
||||
import type { FileReferenceCandidate } from './types.ts'
|
||||
|
||||
@@ -24,7 +23,7 @@ declare module '@deepseek-ai/cordis' {
|
||||
}
|
||||
|
||||
/** Host capability for cancellable file-reference discovery. */
|
||||
export abstract class FileReferenceService extends TypertRemoteService {
|
||||
export abstract class FileReferenceService extends Service {
|
||||
constructor(ctx: Context) {
|
||||
super(ctx, 'fileReferences')
|
||||
}
|
||||
@@ -41,23 +40,6 @@ export abstract class FileReferenceService extends TypertRemoteService {
|
||||
query: string,
|
||||
signal: AbortSignal,
|
||||
): Promise<FileReferenceCandidate[]>
|
||||
|
||||
/**
|
||||
* Remote face of {@link list}; the decorator cannot mark the abstract
|
||||
* member, so this concrete adapter carries the identical contract.
|
||||
* @param agent - target agent whose session cwd bounds discovery.
|
||||
* @param query - path text following `@` or `@"`.
|
||||
* @param signal - caller cancellation.
|
||||
* @returns deterministic path-only candidates.
|
||||
*/
|
||||
@Remote('list')
|
||||
remoteExportList(
|
||||
agent: Agent,
|
||||
query: string,
|
||||
signal: AbortSignal,
|
||||
): Promise<FileReferenceCandidate[]> {
|
||||
return this.list(agent, query, signal)
|
||||
}
|
||||
}
|
||||
|
||||
export default FileReferenceService
|
||||
|
||||
@@ -23,7 +23,7 @@
|
||||
*/
|
||||
|
||||
import { INVALID_CREDENTIAL_CODE, LlmError, normalizeApiKey } from '@deepseek-ai/dsh-llm'
|
||||
import type { LlmDiscoveredModel, LlmModelDiscoveryRequest } from '@deepseek-ai/dsh-llm'
|
||||
import type { LlmDiscoveredModel, LlmModelDiscoveryOperation } from '@deepseek-ai/dsh-llm'
|
||||
import { attributionHeaders } from '@deepseek-ai/dsh-llm'
|
||||
import { catalogModels } from './catalog.ts'
|
||||
|
||||
@@ -193,7 +193,7 @@ function usableProbeKey(raw: string): string {
|
||||
* refuses or fails the request, or the reply is not a model listing.
|
||||
*/
|
||||
export async function discoverModels(
|
||||
request: LlmModelDiscoveryRequest,
|
||||
request: LlmModelDiscoveryOperation,
|
||||
storedApiKey?: () => Promise<string | undefined>,
|
||||
): Promise<readonly LlmDiscoveredModel[]> {
|
||||
// A catalog route already has its answer, and a better one: the installed
|
||||
|
||||
@@ -257,7 +257,10 @@ export function apply(ctx: Context, config: Config): void {
|
||||
// except the credential: a configuration surface edits a redacted descriptor
|
||||
// and never holds a stored secret, so an already-configured route supplies
|
||||
// its own here rather than being interrogated unauthenticated.
|
||||
ctx.llm.registerModelDiscovery(NS, request => discoverModels(request, () => storedApiKey(request.provider)))
|
||||
ctx.llm.registerModelDiscovery(NS, (request, signal) => discoverModels(
|
||||
{ ...request, ...signal === undefined ? {} : { signal } },
|
||||
() => storedApiKey(request.provider),
|
||||
))
|
||||
// Route effects bind to this apply fiber via the stable `ctx` reference,
|
||||
// even when a swap runs inside the scoped settings callback below. A bare
|
||||
// mount (zero routes) is the dormant posture: nothing registers until a
|
||||
|
||||
@@ -34,6 +34,14 @@
|
||||
"types": "./lib/types/message.d.ts",
|
||||
"default": "./lib/types/message.js"
|
||||
},
|
||||
"./typert": {
|
||||
"types": "./lib/typert.host.d.ts",
|
||||
"default": "./lib/typert.host.js"
|
||||
},
|
||||
"./remote": {
|
||||
"types": "./lib/typert.remote-client.d.ts",
|
||||
"default": "./lib/typert.remote-client.js"
|
||||
},
|
||||
"./src/*": "./src/*",
|
||||
"./package.json": "./package.json"
|
||||
},
|
||||
@@ -41,7 +49,11 @@
|
||||
"lib/index.js",
|
||||
"lib/invariant.js",
|
||||
"lib/types/**/*.js",
|
||||
"lib/types/**/*.d.ts"
|
||||
"lib/types/**/*.d.ts",
|
||||
"lib/typert.host.js",
|
||||
"lib/typert.host.d.ts",
|
||||
"lib/typert.remote-client.js",
|
||||
"lib/typert.remote-client.d.ts"
|
||||
],
|
||||
"license": "MIT",
|
||||
"peerDependencies": {
|
||||
@@ -49,17 +61,20 @@
|
||||
"@deepseek-ai/dsh-brand": "workspace:^",
|
||||
"@deepseek-ai/dsh-invariants": "workspace:^",
|
||||
"@deepseek-ai/dsh-timeout": "workspace:^",
|
||||
"@deepseek-ai/dsh-typert-protocol": "workspace:^",
|
||||
"@deepseek-ai/cordis": "workspace:^"
|
||||
},
|
||||
"dependencies": {
|
||||
"@deepseek-ai/dsh-util-crypto": "workspace:^",
|
||||
"@deepseek-ai/schemastery": "workspace:^"
|
||||
"@deepseek-ai/schemastery": "workspace:^",
|
||||
"zod": "^4.4.3"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@deepseek-ai/dsh-attachment": "workspace:^",
|
||||
"@deepseek-ai/dsh-brand": "workspace:^",
|
||||
"@deepseek-ai/dsh-invariants": "workspace:^",
|
||||
"@deepseek-ai/dsh-timeout": "workspace:^",
|
||||
"@deepseek-ai/dsh-typert-protocol": "workspace:^",
|
||||
"@deepseek-ai/cordis": "workspace:^"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6,7 +6,8 @@
|
||||
* @module @deepseek-ai/dsh-llm
|
||||
*/
|
||||
|
||||
import { Context, Service } from '@deepseek-ai/cordis'
|
||||
import { Context } from '@deepseek-ai/cordis'
|
||||
import { Remote, TypertRemoteFailure, TypertRemoteService } from '@deepseek-ai/dsh-typert-protocol'
|
||||
import type {
|
||||
GenerateOptions,
|
||||
LlmConfigurableProvider,
|
||||
@@ -322,12 +323,12 @@ export interface DirectoryRegistrationHandle {
|
||||
* The abstract `llm` service: an adapter registry plus a streaming model-call
|
||||
* API, interceptable via the `llm/stream` waterfall.
|
||||
*/
|
||||
export class LlmRuntime extends Service {
|
||||
export class LlmRuntime extends TypertRemoteService {
|
||||
private adapters = new Map<string, AdapterRegistration>()
|
||||
private directory = new Map<string, LlmConfigurableProvider>()
|
||||
private discoveries = new Map<
|
||||
string,
|
||||
(request: LlmModelDiscoveryRequest) => Promise<readonly LlmDiscoveredModel[]>
|
||||
(request: LlmModelDiscoveryRequest, signal?: AbortSignal) => Promise<readonly LlmDiscoveredModel[]>
|
||||
>()
|
||||
|
||||
constructor(ctx: Context) {
|
||||
@@ -457,6 +458,7 @@ export class LlmRuntime extends Service {
|
||||
* Describe provider routes with a registered adapter.
|
||||
* @returns detached provider metadata in registration order.
|
||||
*/
|
||||
@Remote
|
||||
listProviders(): LlmProviderInfo[] {
|
||||
return [...this.adapters.values()].map(({ provider }) => ({ ...provider }))
|
||||
}
|
||||
@@ -528,6 +530,7 @@ export class LlmRuntime extends Service {
|
||||
* List every declared configurable provider, registered or dormant.
|
||||
* @returns detached directory entries in declaration order.
|
||||
*/
|
||||
@Remote
|
||||
listConfigurableProviders(): LlmConfigurableProvider[] {
|
||||
return [...this.directory.values()].map(entry => ({ ...entry, settingsPath: [...entry.settingsPath] }))
|
||||
}
|
||||
@@ -539,12 +542,15 @@ export class LlmRuntime extends Service {
|
||||
* directory, and because a provider being *added* has no route to name yet.
|
||||
* Disposed with the fiber.
|
||||
* @param settingsNs - the namespace whose profiles this discovery serves.
|
||||
* @param discover - interrogates one endpoint; must honor `request.signal`.
|
||||
* @param discover - interrogates one endpoint and must honor the supplied signal.
|
||||
* @returns the disposer that withdraws the offer.
|
||||
*/
|
||||
registerModelDiscovery(
|
||||
settingsNs: string,
|
||||
discover: (request: LlmModelDiscoveryRequest) => Promise<readonly LlmDiscoveredModel[]>,
|
||||
discover: (
|
||||
request: LlmModelDiscoveryRequest,
|
||||
signal?: AbortSignal,
|
||||
) => Promise<readonly LlmDiscoveredModel[]>,
|
||||
): () => void {
|
||||
const dispose = this.ctx.effect(function* (this: LlmRuntime) {
|
||||
if (settingsNs.length === 0) {
|
||||
@@ -568,11 +574,13 @@ export class LlmRuntime extends Service {
|
||||
* candidate metadata a surface may offer for adoption.
|
||||
* @param settingsNs - namespace whose registered discovery serves this draft.
|
||||
* @param request - the endpoint, protocol, and one-shot credential to use.
|
||||
* @param signal - caller cancellation.
|
||||
* @returns the advertised models, deduplicated in endpoint order.
|
||||
*/
|
||||
async discoverModels(
|
||||
settingsNs: string,
|
||||
request: LlmModelDiscoveryRequest,
|
||||
signal?: AbortSignal,
|
||||
): Promise<LlmDiscoveredModel[]> {
|
||||
const discover = this.discoveries.get(settingsNs)
|
||||
if (discover === undefined) {
|
||||
@@ -583,7 +591,9 @@ export class LlmRuntime extends Service {
|
||||
if ((request.provider ?? '').length === 0 && (request.baseURL ?? '').length === 0) {
|
||||
throw new LlmError('model discovery needs a provider route or a baseURL', 'INVALID_DISCOVERY')
|
||||
}
|
||||
const discovered = await discover(request)
|
||||
const discovered = signal === undefined
|
||||
? await discover(request)
|
||||
: await discover(request, signal)
|
||||
const seen = new Set<string>()
|
||||
const models: LlmDiscoveredModel[] = []
|
||||
for (const model of discovered) {
|
||||
@@ -599,6 +609,34 @@ export class LlmRuntime extends Service {
|
||||
return models
|
||||
}
|
||||
|
||||
/**
|
||||
* Remote adapter for one draft provider interrogation.
|
||||
* @param settingsNs - namespace whose registered discovery serves this draft.
|
||||
* @param request - endpoint, protocol, and one-shot credential to use.
|
||||
* @param signal - caller cancellation supplied by the Remote carrier.
|
||||
* @returns advertised models in endpoint order.
|
||||
* @throws TypertRemoteFailure with `model-discovery-failed` when discovery refuses or fails.
|
||||
*/
|
||||
@Remote('discoverModels')
|
||||
async remoteDiscoverModels(
|
||||
settingsNs: string,
|
||||
request: LlmModelDiscoveryRequest,
|
||||
signal: AbortSignal,
|
||||
): Promise<LlmDiscoveredModel[]> {
|
||||
try {
|
||||
return await this.discoverModels(settingsNs, request, signal)
|
||||
} catch (error: unknown) {
|
||||
throw new TypertRemoteFailure({
|
||||
code: 'model-discovery-failed',
|
||||
message: error instanceof Error ? error.message : String(error),
|
||||
details: {
|
||||
settingsNs,
|
||||
...request.baseURL === undefined ? {} : { baseURL: request.baseURL },
|
||||
},
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve the retry policy captured when one provider route was registered.
|
||||
* @param provider - registered provider route to inspect.
|
||||
|
||||
@@ -247,10 +247,24 @@ export interface LlmModelDiscoveryRequest {
|
||||
api?: string
|
||||
/** Credential for this interrogation alone; the harness never stores it. */
|
||||
apiKey?: string
|
||||
}
|
||||
|
||||
/** Provider-side discovery request with operation-local cancellation attached. */
|
||||
export interface LlmModelDiscoveryOperation extends LlmModelDiscoveryRequest {
|
||||
/** Caller cancellation; implementations must settle promptly after it aborts. */
|
||||
signal?: AbortSignal
|
||||
}
|
||||
|
||||
/** Stable failure returned by the `llm/discoverModels` Remote method. */
|
||||
export interface LlmModelDiscoveryError {
|
||||
readonly code: 'model-discovery-failed'
|
||||
readonly message: string
|
||||
readonly details: {
|
||||
readonly settingsNs: string
|
||||
readonly baseURL?: string
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* One model an endpoint reports about itself. Every field but the id is
|
||||
* optional because most provider listings disclose an id and nothing else;
|
||||
|
||||
@@ -28,6 +28,9 @@
|
||||
},
|
||||
{
|
||||
"path": "../../util/crypto"
|
||||
},
|
||||
{
|
||||
"path": "../../typert/protocol"
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@@ -1,44 +1,16 @@
|
||||
/**
|
||||
* Shared no-shell `execFile` runner for host-native OS integrations (the
|
||||
* native directory chooser, the open-with-default-application hand-off):
|
||||
* utf8 stdio capture, abort propagation, Windows console hide. A library,
|
||||
* not a plugin — no ctx, no state, no events.
|
||||
* Host-native command execution and path-opening utilities.
|
||||
* @module @deepseek-ai/dsh-native-command
|
||||
*/
|
||||
|
||||
import { execFile } from 'node:child_process'
|
||||
|
||||
/** Testable command boundary; native implementations never invoke a shell. */
|
||||
export type NativeCommandRunner = (
|
||||
command: string,
|
||||
args: readonly string[],
|
||||
signal: AbortSignal,
|
||||
) => Promise<{ stdout: string; stderr: string }>
|
||||
|
||||
/**
|
||||
* Run a host command with utf8 stdio, abort propagation, and Windows hide.
|
||||
* @param command - executable path or PATH name.
|
||||
* @param args - argv (never a shell string).
|
||||
* @param signal - caller/connection lifetime; abort terminates the child.
|
||||
* @returns captured stdout/stderr on exit 0.
|
||||
*/
|
||||
export const runNativeCommand: NativeCommandRunner = (command, args, signal) =>
|
||||
new Promise((resolve, reject) => {
|
||||
execFile(
|
||||
command,
|
||||
[...args],
|
||||
{ encoding: 'utf8', signal, windowsHide: true },
|
||||
(error, stdout, stderr) => {
|
||||
if (error !== null) {
|
||||
const failure = Object.assign(new Error(error.message, { cause: error }), {
|
||||
code: error.code,
|
||||
stdout,
|
||||
stderr,
|
||||
})
|
||||
reject(failure)
|
||||
return
|
||||
}
|
||||
resolve({ stdout, stderr })
|
||||
},
|
||||
)
|
||||
})
|
||||
export { runNativeCommand } from './runner.ts'
|
||||
export type { NativeCommandRunner } from './runner.ts'
|
||||
export {
|
||||
canOpenNativePath,
|
||||
openNativePath,
|
||||
openNativeTextFile,
|
||||
} from './path-opener.ts'
|
||||
export type {
|
||||
PathOpenerInternals,
|
||||
PathOpenerRunner,
|
||||
} from './path-opener.ts'
|
||||
|
||||
@@ -0,0 +1,203 @@
|
||||
/**
|
||||
* Cross-platform native path and text-document openers for Host UI
|
||||
* integrations.
|
||||
*
|
||||
* 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.
|
||||
* @module @deepseek-ai/dsh-native-command/path-opener
|
||||
*/
|
||||
|
||||
import { release as osRelease } from 'node:os'
|
||||
import { extname } from 'node:path'
|
||||
import { runNativeCommand, type NativeCommandRunner } from './runner.ts'
|
||||
|
||||
/** 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)
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
/**
|
||||
* Shared no-shell `execFile` runner for host-native OS integrations.
|
||||
* @module @deepseek-ai/dsh-native-command/runner
|
||||
*/
|
||||
|
||||
import { execFile } from 'node:child_process'
|
||||
|
||||
/** Testable command boundary; native implementations never invoke a shell. */
|
||||
export type NativeCommandRunner = (
|
||||
command: string,
|
||||
args: readonly string[],
|
||||
signal: AbortSignal,
|
||||
) => Promise<{ stdout: string; stderr: string }>
|
||||
|
||||
/**
|
||||
* Run a host command with utf8 stdio, abort propagation, and Windows hide.
|
||||
* @param command - executable path or PATH name.
|
||||
* @param args - argv (never a shell string).
|
||||
* @param signal - caller/connection lifetime; abort terminates the child.
|
||||
* @returns captured stdout/stderr on exit 0.
|
||||
*/
|
||||
export const runNativeCommand: NativeCommandRunner = (command, args, signal) =>
|
||||
new Promise((resolve, reject) => {
|
||||
execFile(
|
||||
command,
|
||||
[...args],
|
||||
{ encoding: 'utf8', signal, windowsHide: true },
|
||||
(error, stdout, stderr) => {
|
||||
if (error !== null) {
|
||||
const failure = Object.assign(new Error(error.message, { cause: error }), {
|
||||
code: error.code,
|
||||
stdout,
|
||||
stderr,
|
||||
})
|
||||
reject(failure)
|
||||
return
|
||||
}
|
||||
resolve({ stdout, stderr })
|
||||
},
|
||||
)
|
||||
})
|
||||
Reference in New Issue
Block a user