refactor(api): expose remaining domain remotes

This commit is contained in:
imccyu
2026-08-27 21:57:55 +08:00
parent 12efd638d4
commit 2d4393d842
21 changed files with 821 additions and 92 deletions
@@ -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
+92 -3
View File
@@ -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'>