feat: slash system / input service / agent scope
This commit is contained in:
121
packages/client/ui-skill/src/client/index.ts
Normal file
121
packages/client/ui-skill/src/client/index.ts
Normal file
@@ -0,0 +1,121 @@
|
||||
/**
|
||||
* Skill reference plugin, browser half: registers the '/' skill source —
|
||||
* candidates from the skill.list RPC addressed by the per-call session
|
||||
* projection's sessionId (sessions are always agent-backed; the host
|
||||
* resolves cwd from the session header), pick inserts the literal `/name `
|
||||
* text (decision 21: the draft carries plain text, chip visuals are derived
|
||||
* by scanning against the source lexicon, and the prompt ships the same
|
||||
* literal — no `<skill>` tag). The RPC rides the plugin's root-context
|
||||
* connection captured at registration — the source never reads services off
|
||||
* a per-call argument. No adjudication hooks: skill references ride
|
||||
* ordinary prompts and never enter command adjudication.
|
||||
*
|
||||
* Catalog fetches are cached per session (the small twin of the ui-command
|
||||
* directory): the per-keystroke candidates re-poll filters a settled
|
||||
* snapshot locally, so one session costs one RPC. The scope-birth warm hook
|
||||
* prewarms the session's key; connection/reset clears everything — the host
|
||||
* catalog may differ across generations. A shared in-flight fetch
|
||||
* deliberately outlives any single menu interaction: closing the menu must
|
||||
* not kill the prewarm other consumers will hit, so it carries its own
|
||||
* abort (fired only on invalidation/teardown) while a candidates caller
|
||||
* with an aborted signal just returns early.
|
||||
*/
|
||||
import type { ConnectionHandle, SessionId, SkillEntry } from '@deepseek-ai/dsh-client-connection/client'
|
||||
import type { ClientContext } from '@deepseek-ai/dsh-client-runtime/client'
|
||||
import type { SlashServiceContract, SlashSource } from '@deepseek-ai/dsh-client-ui-slash/client'
|
||||
|
||||
/** One session's catalog fetch: the shared promise plus its own abort handle. */
|
||||
interface CatalogFetch {
|
||||
readonly promise: Promise<readonly SkillEntry[]>
|
||||
readonly abort: AbortController
|
||||
/** Settled catalog for synchronous lexicon reads (unset while in flight or on failure). */
|
||||
settled?: readonly SkillEntry[]
|
||||
}
|
||||
|
||||
/** Required services: the slash registry + the wire face the source closes over. */
|
||||
export const inject = ['slash', 'connection']
|
||||
|
||||
/**
|
||||
* Client plugin body: register the '/' skill source over the root wire face.
|
||||
* @param ctx - client root context.
|
||||
*/
|
||||
export function apply(ctx: ClientContext): void {
|
||||
const { list } = (ctx.get('connection') as ConnectionHandle).api.skills
|
||||
// Session-keyed catalog cache; single-flight per key. Plugin-closure state:
|
||||
// the fiber effect below is its teardown boundary.
|
||||
const fetches = new Map<SessionId, CatalogFetch>()
|
||||
|
||||
const fetchCatalog = (sessionId: SessionId): Promise<readonly SkillEntry[]> => {
|
||||
const existing = fetches.get(sessionId)
|
||||
if (existing !== undefined) return existing.promise
|
||||
const abort = new AbortController()
|
||||
const promise = (async () => {
|
||||
const { result } = await list({ sessionId }, abort.signal)
|
||||
if (!result.ok) throw new Error(`skill.list failed: ${result.error.code}: ${result.error.message}`)
|
||||
return result.value.skills
|
||||
})()
|
||||
const entry: CatalogFetch = { promise, abort }
|
||||
fetches.set(sessionId, entry)
|
||||
promise.then(
|
||||
// Settled snapshot backs the synchronous lexicon reads.
|
||||
(skills) => { entry.settled = skills },
|
||||
// A failed fetch must not poison the key: the next consumer retries.
|
||||
() => {
|
||||
if (fetches.get(sessionId) === entry) fetches.delete(sessionId)
|
||||
},
|
||||
)
|
||||
return promise
|
||||
}
|
||||
|
||||
const invalidate = (key: SessionId): void => {
|
||||
const entry = fetches.get(key)
|
||||
if (entry === undefined) return
|
||||
fetches.delete(key)
|
||||
entry.abort.abort()
|
||||
}
|
||||
|
||||
const clearAll = (): void => {
|
||||
for (const key of [...fetches.keys()]) invalidate(key)
|
||||
}
|
||||
|
||||
const source: SlashSource = {
|
||||
trigger: '/',
|
||||
name: 'skill',
|
||||
async candidates(session, { query, signal }) {
|
||||
const skills = await fetchCatalog(session.sessionId)
|
||||
// Superseded keystroke: the shared fetch stays warm, this caller yields.
|
||||
if (signal.aborted) return []
|
||||
return skills
|
||||
.filter((skill) => skill.name.startsWith(query))
|
||||
.map((skill) => ({ name: skill.name, description: skill.description }))
|
||||
},
|
||||
warm(session) {
|
||||
// Fire-and-forget scope-birth prewarm; the shared fetch reports
|
||||
// through candidates.
|
||||
fetchCatalog(session.sessionId).catch(() => {})
|
||||
},
|
||||
lexicon(session) {
|
||||
return fetches.get(session.sessionId)?.settled?.map((skill) => skill.name)
|
||||
},
|
||||
onPick({ candidate }) {
|
||||
// Decision 21: plain-text reference — the literal lands in the draft
|
||||
// and ships to the model verbatim (trailing space closes the token).
|
||||
// Legacy path (decision 21), retained for the removal cut, no longer reached:
|
||||
// return { insert: { source: 'skill', ref: candidate.name, label: candidate.name, clipboardText: `/${candidate.name}` } }
|
||||
return { text: `/${candidate.name} ` }
|
||||
},
|
||||
codec: {
|
||||
clipboardText: (ref) => `/${ref}`,
|
||||
serialize: (ref) => Promise.resolve(`<skill>${ref}</skill>`),
|
||||
},
|
||||
}
|
||||
const slash = ctx.get('slash') as SlashServiceContract
|
||||
ctx.on('connection/reset', clearAll)
|
||||
ctx.effect(() => {
|
||||
const unregister = slash.registerSource(source)
|
||||
return () => {
|
||||
unregister()
|
||||
clearAll()
|
||||
}
|
||||
}, 'ui-skill: source')
|
||||
}
|
||||
6
packages/client/ui-skill/src/css-modules.d.ts
vendored
Normal file
6
packages/client/ui-skill/src/css-modules.d.ts
vendored
Normal file
@@ -0,0 +1,6 @@
|
||||
declare module '*.module.css' {
|
||||
const classes: Record<string, string>
|
||||
export default classes
|
||||
}
|
||||
|
||||
declare module '*.css'
|
||||
9
packages/client/ui-skill/src/index.ts
Normal file
9
packages/client/ui-skill/src/index.ts
Normal file
@@ -0,0 +1,9 @@
|
||||
/**
|
||||
* Skill reference plugin, node half. Pure UI plugin: the empty apply
|
||||
* exists so the plugin appears in the host cordis.yml / Loader; the browser
|
||||
* half ships via exports["./client"], discovered through the package.json
|
||||
* dshClient declaration.
|
||||
*/
|
||||
|
||||
/** Host plugin body — no host-side behavior for this source plugin. */
|
||||
export function apply(): void {}
|
||||
31
packages/client/ui-skill/src/invariant.ts
Normal file
31
packages/client/ui-skill/src/invariant.ts
Normal file
@@ -0,0 +1,31 @@
|
||||
/**
|
||||
* Package-owned invariant companion for `@deepseek-ai/dsh-client-ui-skill`.
|
||||
* @module @deepseek-ai/dsh-client-ui-skill/invariant
|
||||
*/
|
||||
|
||||
/* jscpd:ignore-start */
|
||||
import type { Context } from 'cordis'
|
||||
import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants'
|
||||
|
||||
const PACKAGE_NAME = '@deepseek-ai/dsh-client-ui-skill'
|
||||
|
||||
/** Cordis companion plugin name. */
|
||||
export const name = 'client-ui-skill-invariant'
|
||||
/** Service required before the companion can reserve package ownership. */
|
||||
export const inject = ['invariants']
|
||||
|
||||
/**
|
||||
* No runtime invariant: a single slash-source registration whose disposal is
|
||||
* proven by the HMR-safety spec — it emits no cordis events and owns no
|
||||
* cross-plugin mutable state.
|
||||
*/
|
||||
const install: InvariantInstaller = () => {}
|
||||
|
||||
/**
|
||||
* Register this package's invariant companion.
|
||||
* @param ctx - Cordis context carrying the invariant service.
|
||||
* @returns the installed registration's disposer after setup succeeds.
|
||||
*/
|
||||
export const apply = (ctx: Context): Promise<() => void> =>
|
||||
Promise.resolve(ctx.invariants.register(PACKAGE_NAME, install))
|
||||
/* jscpd:ignore-end */
|
||||
Reference in New Issue
Block a user