feat: slash system / input service / agent scope

This commit is contained in:
imccyu
2026-07-27 03:17:52 +08:00
parent f3a4833dbf
commit a27be43ac1
210 changed files with 15969 additions and 2213 deletions

View File

@@ -0,0 +1,29 @@
# @deepseek-ai/dsh-client-ui-skill
Skill reference source, browser half: registers the `/`-trigger `skill` source into `ctx.slash`. Candidates come from the `skill.list` RPC addressed by the per-call `ClientSessionContext` projection's `{sessionId}` — every session is agent-backed and the host resolves `cwd` from the session header. Catalogs cache per session with a single-flight fetch; the scope-birth `warm` hook prewarms the session's entry and `connection/reset` clears everything. Results filter by `startsWith(query)`; picking a candidate lands the literal `/name ` text through the slash pipeline (decision 21 plain-text reference), and the source `codec` owns the reference's two projections: `clipboardText` → `/name`, `serialize` → the model form `<skill>name</skill>` invoked at submit time. The RPC rides the plugin's root-context connection captured at registration — the source never reads services off a per-call argument. The source implements no `matchSpace`/`matchEnter` hooks — skill references never enter command adjudication and ride ordinary prompts into the default sink.
A failed `skill.list` throws from `candidates`, which the slash shell logs and folds into a silent menu-group drop — the menu shows only pending/ready states.
The `/client` export surface is the plugin body (`apply`/`inject`) only; the source object is internal to the registration effect.
## Model Experience
### Skill reference text in the user prompt
#### What the model sees
A picked candidate lands the literal `/name ` in the draft (decision 21: plain text, no `<skill>` tag); the text reaches the model verbatim inside the ordinary user message (`session.prompt`), with no dedicated content block, prompt section, or host-side expansion. The association with the actual skill is model-side and non-deterministic: the session prefix already carries the skill catalog (rendered by `dsh-tool-skill`), and the reference's name matching a catalog entry is what invites the model to load it.
#### Token effect
Conditional and tiny: only a pick (or hand-typing the same text) adds the reference's characters to that one user message. Menu browsing and the candidate fetch add zero model tokens.
#### KV Cache effect
Append-only: the reference is part of a new user message appended after the reusable history prefix. This package never edits earlier request tokens.
## Known Limitations and Deferred Work
- **Non-deterministic skill loading** — the reference is a collaboration cue, not a guarantee; the model may ignore it. The rework path when hit rate proves insufficient (a host-side `context/skill-reference` guidance package, or full-text injection) sits in the design ledger; the wire text shape would not change.
- **First keystroke may race the prewarm** — the scope-birth warm launches the catalog fetch, but a menu opened before it settles shows no skill candidates for that keystroke. Accepted by design: skill references do not participate in enter adjudication, so nothing correctness-bearing waits on the catalog.
- **Text is the truth** — the reference is plain draft text; a hand-typed identical token is the same reference. Chip visuals derive from the lexicon scan; no occurrence identity or position tracking (componentized chips are a ledger item).

View File

@@ -0,0 +1,61 @@
{
"name": "@deepseek-ai/dsh-client-ui-skill",
"description": "Skill reference source: '/' menu candidates from skill.list, inserts <skill>name</skill> references",
"version": "0.0.1",
"private": true,
"type": "module",
"main": "lib/index.js",
"types": "lib/types/index.d.ts",
"exports": {
".": {
"types": "./lib/types/index.d.ts",
"default": "./lib/index.js"
},
"./invariant": {
"types": "./lib/types/invariant.d.ts",
"default": "./lib/invariant.js"
},
"./client": {
"types": "./lib/types/client/index.d.ts",
"default": "./lib/client.js"
},
"./src/*": "./src/*",
"./package.json": "./package.json"
},
"dshClient": {
"inject": [
"@deepseek-ai/dsh-client-runtime",
"@deepseek-ai/dsh-client-ui-slash"
],
"platform": "web"
},
"scripts": {
"bundle": "tsdown",
"watch": "tsdown --watch"
},
"license": "BSD-3-Clause",
"peerDependencies": {
"@deepseek-ai/dsh-client-connection": "^0.0.1",
"@deepseek-ai/dsh-client-runtime": "^0.0.1",
"@deepseek-ai/dsh-client-ui-slash": "^0.0.1",
"@deepseek-ai/dsh-client-ui-slots": "^0.0.1",
"@deepseek-ai/dsh-invariants": "^0.0.1",
"cordis": "^4.0.0-rc.7"
},
"devDependencies": {
"@deepseek-ai/dsh-client-connection": "workspace:^",
"@deepseek-ai/dsh-client-runtime": "workspace:^",
"@deepseek-ai/dsh-client-ui-slash": "workspace:^",
"@deepseek-ai/dsh-client-ui-slots": "workspace:^",
"@deepseek-ai/dsh-invariants": "workspace:^",
"cordis": "^4.0.0-rc.7"
},
"files": [
"lib/index.js",
"lib/invariant.js",
"lib/client.js",
"lib/types/**/*.d.ts",
"lib/types/**/*.d.ts.map",
"src"
]
}

View 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')
}

View File

@@ -0,0 +1,6 @@
declare module '*.module.css' {
const classes: Record<string, string>
export default classes
}
declare module '*.css'

View 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 {}

View 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 */

View File

@@ -0,0 +1,240 @@
/**
* ui-skill browser half: source registration (duplicate-name proof) +
* fiber-teardown removal (HMR safety) against the real SlashService, then
* the source behavior contract driven directly on the captured source with
* real ClientSessionContext projections — sessionId addressing, the
* session-keyed catalog cache (single-flight per key, scope-birth warm
* prewarm, connection/reset clear), startsWith filtering, RPC-failure
* rejection, pick → plain-text outcome (decision 21), the synchronous
* lexicon reads over the settled cache, and the reference codec's two
* projections. Direct driving is deliberate: this spec owns only the
* source's own contract.
*/
import { Context } from 'cordis'
import { describe, expect, it, vi } from 'vitest'
import type { SessionId } from '@deepseek-ai/dsh-client-runtime/client'
import { SlashService } from '@deepseek-ai/dsh-client-ui-slash/client'
import type { ClientSessionContext, SlashSource } from '@deepseek-ai/dsh-client-ui-slash/client'
import { apply, inject } from '../src/client/index.ts'
type SkillRow = { name: string; description: string; whenToUse?: string }
type ListResult =
| { ok: true; value: { skills: SkillRow[] } }
| { ok: false; error: { code: string; message: string; details: object } }
type ListFn = (payload: object, signal?: AbortSignal) => Promise<{ result: ListResult }>
/** Boot the plugin over fake slash/connection faces; returns the captured source and its ctx. */
async function bench(list: ListFn) {
const ctx = new Context()
let captured: SlashSource | undefined
ctx.provide('slash', { registerSource: (src: SlashSource) => { captured = src; return () => {} } })
ctx.provide('connection', { api: { skills: { list } } })
await ctx.plugin({ inject: [...inject], apply }).await()
return { ctx, source: captured! }
}
const CATALOG: SkillRow[] = [
{ name: 'commit-helper', description: 'commit flow' },
{ name: 'code-review', description: 'review flow', whenToUse: 'reviews' },
{ name: 'deploy', description: 'deploy flow' },
]
const listOk = (skills: SkillRow[]): ListFn => () => Promise.resolve({ result: { ok: true as const, value: { skills } } })
/** Counting fake: records payloads, resolves the shared catalog. */
function countingList(skills: SkillRow[] = CATALOG) {
const payloads: object[] = []
const list: ListFn = (payload) => {
payloads.push(payload)
return listOk(skills)(payload)
}
return { list, payloads }
}
const sid = (id: string) => id as SessionId
const proj = (id: string): ClientSessionContext => ({ sessionId: sid(id) })
const req = (query: string, signal?: AbortSignal) =>
({ query, position: 'leading' as const, signal: signal ?? new AbortController().signal })
describe('apply', () => {
it('declares the services it binds', () => {
expect(inject).toEqual(['slash', 'connection'])
})
it('registers the "/" skill source; disposal frees the name (HMR safety)', async () => {
const ctx = new Context()
// SlashService itself injects 'sessions'; the stub unblocks its fiber.
ctx.provide('sessions', {})
await ctx.plugin(SlashService).await()
ctx.provide('connection', { api: { skills: { list: listOk(CATALOG) } } })
const fiber = ctx.plugin({ inject: [...inject], apply })
await fiber.await()
const slash = ctx.get('slash') as SlashService
const rival = {
trigger: '/' as const,
name: 'skill',
candidates: () => Promise.resolve([]),
onPick: () => undefined,
}
// Live registration holds the (trigger, name) seat…
expect(() => slash.registerSource(rival)).toThrow(/already registered/)
// …and fiber teardown releases it.
await fiber.dispose()
expect(() => slash.registerSource(rival)).not.toThrow()
})
})
describe('candidates: sessionId addressing', () => {
it('lists via {sessionId} and filters by startsWith(query)', async () => {
const { list, payloads } = countingList()
const { source } = await bench(list)
const items = await source.candidates(proj('s1'), req('co'))
// Exact payload: session address only — no agent or transport vocabulary.
expect(payloads).toEqual([{ sessionId: 's1' }])
expect(items).toEqual([
{ name: 'commit-helper', description: 'commit flow' },
{ name: 'code-review', description: 'review flow' },
])
})
it('rejects on a failed result (the slash shell owns the menu-side fold)', async () => {
const { source } = await bench(() => Promise.resolve({
result: { ok: false, error: { code: 'internal', message: 'boom', details: {} } },
}))
await expect(source.candidates(proj('s1'), req('co')))
.rejects.toThrow('skill.list failed: internal: boom')
})
})
describe('catalog cache', () => {
it('re-polls on the same session filter locally: one RPC across keystrokes', async () => {
const { list, payloads } = countingList()
const { source } = await bench(list)
await source.candidates(proj('s1'), req(''))
const second = await source.candidates(proj('s1'), req('co'))
expect(payloads).toHaveLength(1)
expect(second).toEqual([
{ name: 'commit-helper', description: 'commit flow' },
{ name: 'code-review', description: 'review flow' },
])
// A different session is its own key — one more RPC, not two.
await source.candidates(proj('s2'), req(''))
expect(payloads).toEqual([{ sessionId: 's1' }, { sessionId: 's2' }])
})
it('single-flight: concurrent candidates on one cold key share one RPC', async () => {
const { list, payloads } = countingList()
const { source } = await bench(list)
const [a, b] = await Promise.all([
source.candidates(proj('s1'), req('dep')),
source.candidates(proj('s1'), req('co')),
])
expect(payloads).toHaveLength(1)
expect(a).toEqual([{ name: 'deploy', description: 'deploy flow' }])
expect(b).toHaveLength(2)
})
it('an aborted caller yields empty but leaves the shared fetch warm', async () => {
const { list, payloads } = countingList()
const { source } = await bench(list)
const aborted = new AbortController()
aborted.abort()
await expect(source.candidates(proj('s1'), req('co', aborted.signal))).resolves.toEqual([])
// The fetch settled into the cache: the next caller pays zero RPC.
await expect(source.candidates(proj('s1'), req('co'))).resolves.toHaveLength(2)
expect(payloads).toHaveLength(1)
})
it('a failed fetch does not poison the key: the next caller retries', async () => {
let fail = true
const payloads: object[] = []
const { source } = await bench((payload) => {
payloads.push(payload)
return fail
? Promise.resolve({ result: { ok: false as const, error: { code: 'internal', message: 'boom', details: {} } } })
: listOk(CATALOG)(payload)
})
await expect(source.candidates(proj('s1'), req(''))).rejects.toThrow('boom')
fail = false
await expect(source.candidates(proj('s1'), req(''))).resolves.toHaveLength(3)
expect(payloads).toHaveLength(2)
})
it('the scope-birth warm prewarms the session key fire-and-forget', async () => {
const { list, payloads } = countingList()
const { source } = await bench(list)
source.warm!(proj('s1'))
await vi.waitFor(() => { expect(payloads).toHaveLength(1) })
expect(payloads[0]).toEqual({ sessionId: 's1' })
// The prewarmed key serves candidates with zero further RPC; other
// sessions' keys stay untouched.
await expect(source.candidates(proj('s1'), req(''))).resolves.toHaveLength(3)
expect(payloads).toHaveLength(1)
await source.candidates(proj('s2'), req(''))
expect(payloads).toHaveLength(2)
})
it('connection/reset clears every cached session', async () => {
const { list, payloads } = countingList()
const { ctx, source } = await bench(list)
await source.candidates(proj('s1'), req(''))
await source.candidates(proj('s2'), req(''))
expect(payloads).toHaveLength(2)
ctx.emit('connection/reset')
await source.candidates(proj('s1'), req(''))
await source.candidates(proj('s2'), req(''))
expect(payloads).toHaveLength(4)
})
})
describe('lexicon', () => {
it('is undefined before the session catalog settles and serves names after', async () => {
let release: (() => void) | undefined
const gate = new Promise<void>((resolve) => { release = resolve })
const { source } = await bench(async (payload) => {
await gate
return listOk(CATALOG)(payload)
})
// Cold: nothing cached for the session.
expect(source.lexicon!(proj('s1'))).toBeUndefined()
const pending = source.candidates(proj('s1'), req(''))
// In flight: still no synchronous snapshot.
expect(source.lexicon!(proj('s1'))).toBeUndefined()
release!()
await pending
expect(source.lexicon!(proj('s1'))).toEqual(['commit-helper', 'code-review', 'deploy'])
// Another session's key is independent — cold until its own fetch.
expect(source.lexicon!(proj('s2'))).toBeUndefined()
})
})
describe('pick and codec', () => {
it('onPick returns the literal /name text with a closing space (decision 21)', async () => {
const { source } = await bench(listOk(CATALOG))
const outcome = source.onPick({
candidate: { name: 'commit-helper', description: 'commit flow' },
session: proj('s1'),
position: 'leading',
via: 'menu',
span: { start: 0, end: 4, draftRev: 7 },
})
expect(outcome).toEqual({ text: '/commit-helper ' })
})
it('codec projects clipboard `/name` and serializes the model form <skill>name</skill>', async () => {
const { source } = await bench(listOk(CATALOG))
expect(source.codec!.clipboardText('deploy')).toBe('/deploy')
await expect(source.codec!.serialize('deploy', new AbortController().signal))
.resolves.toBe('<skill>deploy</skill>')
})
})
describe('adjudication', () => {
it('never participates: no matchSpace/matchEnter hooks on the skill source', async () => {
const { source } = await bench(listOk(CATALOG))
expect(source.matchSpace).toBeUndefined()
expect(source.matchEnter).toBeUndefined()
})
})

View File

@@ -0,0 +1,30 @@
{
"extends": "../../../tsconfig.base.client.json",
"compilerOptions": {
"rootDir": "src",
"outDir": "lib/types"
},
"include": [
"src"
],
"references": [
{
"path": "../../../vendor/cordis"
},
{
"path": "../connection"
},
{
"path": "../runtime"
},
{
"path": "../ui-slash"
},
{
"path": "../ui-slots"
},
{
"path": "../../support/invariants"
}
]
}

View File

@@ -0,0 +1,3 @@
import { clientBundle } from '../tsdown.client.ts'
export default clientBundle('@deepseek-ai/dsh-client-ui-skill', ['lib/types/index.js', 'lib/types/invariant.js'])