Merge remote-tracking branch 'origin/master' into docs/post-v3-release-proofreading
# Conflicts: # .agents/notes/implemented/architecture/2026-06-20-generic-long-running-tool-runtime.i18n.yaml # .agents/notes/implemented/architecture/2026-06-20-generic-long-running-tool-runtime.zh.md # README.i18n.yaml # README.zh.md # scripts/snapshots/translation-prompt-v4/request-response.expected.json
This commit is contained in:
@@ -2,5 +2,5 @@
|
||||
# side as of the last confirmed-consistent state. Both languages carry equal authority;
|
||||
# after editing either side, bring the other along and re-record with:
|
||||
# pnpm run verify-translation-pairing --write packages/host/README.md
|
||||
README.md: 926cb0b6b87a8ee76cb2dab745a31f620f4e7f5c
|
||||
README.zh.md: 7ef057ee56e56ddc2baa7092ccbe44fb161b7448
|
||||
README.md: 1c3b6ab3192fe35a5532183414e45d1b02325e57
|
||||
README.zh.md: a062d5fce055e3266953993d532a86bec1375377
|
||||
|
||||
@@ -13,6 +13,7 @@ The host side of the dsh web GUI: the API gateway every client shape shares, and
|
||||
| [`directory-picker-native/`](directory-picker-native/README.md) | Native directory-picker backend and browser interaction | registers `ctx.directoryPicker` |
|
||||
| [`directory-picker-browse/`](directory-picker-browse/README.md) | In-app directory-browser backend and interaction | registers `ctx.directoryPicker` |
|
||||
| [`directory-picker-auto/`](directory-picker-auto/README.md) | Host-adaptive picker composition | mounts a backend |
|
||||
| [`plugin-inventory/`](plugin-inventory/README.md) | Read-only projection of current Loader entries | Remote `pluginInventory/list` |
|
||||
|
||||
`apiproxy` remains transport-independent; [`client/connection`](../client/connection/README.md) supplies the browser/HTTP carrier. Picker implementations replace one another behind the shared seam.
|
||||
|
||||
|
||||
@@ -13,6 +13,7 @@ dsh Web GUI 的宿主侧:所有客户端形态共享的 API 网关,以及承
|
||||
| [`directory-picker-native/`](directory-picker-native/README.md) | 原生目录选择器后端和浏览器交互 | 注册 `ctx.directoryPicker` |
|
||||
| [`directory-picker-browse/`](directory-picker-browse/README.md) | 应用内目录浏览器后端和交互 | 注册 `ctx.directoryPicker` |
|
||||
| [`directory-picker-auto/`](directory-picker-auto/README.md) | 宿主自适应选择器组合 | 挂载一个后端 |
|
||||
| [`plugin-inventory/`](plugin-inventory/README.md) | 当前 Loader 条目的只读投影 | Remote `pluginInventory/list` |
|
||||
|
||||
`apiproxy` 保持传输无关;[`client/connection`](../client/connection/README.md) 提供浏览器/HTTP 载体。选择器实现可在共享 seam 后互相替换。
|
||||
|
||||
|
||||
@@ -2,5 +2,5 @@
|
||||
# side as of the last confirmed-consistent state. Both languages carry equal authority;
|
||||
# after editing either side, bring the other along and re-record with:
|
||||
# pnpm run verify-translation-pairing --write packages/host/apiproxy/README.md
|
||||
README.md: 5915d20b176ed6eccdb2c939bdf58b0a122271c5
|
||||
README.zh.md: 54fcccc3fef46e717aaf05e3a7ace732a0f4b74a
|
||||
README.md: 059c3eacbcd47bfc39820ab3db5545dbc2e2ccb8
|
||||
README.zh.md: 4c7e97233a9ebf766ff75daf5cb71ff9d2d22d88
|
||||
|
||||
@@ -26,7 +26,7 @@ Question responses are validated against their pending request before the first
|
||||
|
||||
`session.history` reads an attached Session in memory or inspects a cold log through persistence without resuming or publishing an Agent, then pages on append-origin message boundaries. `maxMessages` counts `user/message` and `assistant/message` events that entered the surface by appending, so a model-only replacement copy consumes no quota. Each page stays one contiguous raw event range, which keeps a compaction's log-only `compact/summary` record on the same page as the replacement that cites it.
|
||||
|
||||
`session.history`'s tail page (`beforeSeq` absent) additionally carries an optional `projections` block — the watermark snapshot of every unit registered on `ctx.sessionProjections` (`@deepseek-ai/dsh-session-projection`), with `asOfSeq` = the last event seq the values reflect (`-1` on an empty log). The gateway also subscribes to the registry's change feed and mints a `session/projection` mux frame per changed unit (`{sessionId, key, value, seq}` — live push state, never logged; clients hold one generic per-session value store under higher-seq-wins). The carrier holds zero domain knowledge (each value passed its unit's own schema inside the registry; the wire schemas keep `values`/`value` wide); loadOlder pages never carry the block, and a composition without the registry serves histories without either surface.
|
||||
`session.history`'s tail page (`beforeSeq` absent) additionally carries an optional `projections` block — the watermark snapshot of every unit registered on `ctx.sessionProjections` (`@deepseek-ai/dsh-session-projection`), with `asOfSeq` = the last event seq the values reflect (`-1` on an empty log). The gateway also subscribes to the registry's change feed and mints a `session/projection` mux frame per changed unit (`{sessionId, key, value, seq}` — live push state, never logged; clients hold one generic per-session value store under higher-seq-wins). The carrier holds no other domain's knowledge (each value passed its unit's own schema inside the registry; the wire schemas keep `values`/`value` wide); loadOlder pages never carry the block, and a composition without the registry serves histories without either surface. The gateway registers exactly one unit of its own: `imageLimits`, the attachments config it enforces at prompt admission, published as a per-boot constant (`apply` keeps the state reference, so baselines alone carry it — no change frames) so clients can refuse an over-limit intake before submit and label upload affordances; the unit activates only while both the registry and the attachments service are composed.
|
||||
|
||||
Session-log export is a host-only download surface, not an RPC: `GET /api/session.export?sessionId=…&includeDescendants=true` streams a ZIP whose files are each session's stored artifact text verbatim (the persistence backend's `readRaw` — exact durable bytes decoded from the physical encoding, never a reconstruction from parsed events), root under its original base name plus each subagent descendant under `subagents/<id>/`, and every image any included log references under `media/<attachmentId>.<ext>` (read and verified from the attachment store; a shared image appears once). Each live root or descendant crosses the authoritative `SessionStore.flush` durability barrier immediately before its raw artifact read; cold sessions have no in-memory work to flush. Compression runs on the host with fflate's streaming Zip API at validated `sessionExportCompressionLevel` 0–9 (default 6), so deployments can trade CPU and latency against archive size; the response is chunked as it is produced and the host never holds the whole archive in one buffer. Once the response queue reaches its 64 KiB byte high-water mark, production waits until consumer pull restores positive capacity; fflate's synchronous callback can overshoot that bound only by the output of one bounded input push. Request abort and response-body cancellation stop lineage and artifact work, terminate the active compressor, and propagate as cancellation rather than an HTTP 500. It requires the persistence, session-query, and attachment services: a deployment without any answers 500, a persistence backend without per-session raw artifacts answers 501, a missing root session answers 404, and a descendant without a stored artifact or a referenced image that cannot be read fails the stream (fail-loud, never silent under-export). The carrier mounts the endpoint; `ApiProxy.downloads.sessionLog` implements it.
|
||||
|
||||
|
||||
@@ -26,7 +26,7 @@ Settings 分节中的 `reasoningEffort` 在 agent-default-model 插件配置中
|
||||
|
||||
`session.history` 会读取已附加 Session 的内存状态,或通过持久化检查冷日志,而不会恢复或发布 agent,然后按追加来源的消息边界分页:`maxMessages` 统计以追加方式进入 surface 的 `user/message` 和 `assistant/message` 事件,因此仅供模型使用的替换副本不占用配额。每一页仍是一段连续的原始事件区间,从而让压缩(compaction)的仅日志 `compact/summary` 记录与引用它的替换留在同一页。
|
||||
|
||||
`session.history` 的尾页(不带 `beforeSeq`)额外携带一个可选的 `projections` 块——`ctx.sessionProjections`(`@deepseek-ai/dsh-session-projection`)上每个已注册单元的水位线快照,`asOfSeq` = 这些值共同反映到的最后一个事件 seq(空日志为 `-1`)。网关还订阅注册表的变更流,为每个状态发生变化的单元生成一个 `session/projection` mux 帧(`{sessionId, key, value, seq}`——实时推送状态,绝不入日志;客户端按 seq 高者胜维护一个按会话的通用值仓)。载体不持有任何领域知识(每个值在注册表内部已过其单元自己的 schema;协议 schema 对 `values`/`value` 保持宽松);loadOlder 页永不携带该块,未装注册表的组合则两个面都不提供。
|
||||
`session.history` 的尾页(不带 `beforeSeq`)额外携带一个可选的 `projections` 块——`ctx.sessionProjections`(`@deepseek-ai/dsh-session-projection`)上每个已注册单元的水位线快照,`asOfSeq` = 这些值共同反映到的最后一个事件 seq(空日志为 `-1`)。网关还订阅注册表的变更流,为每个状态发生变化的单元生成一个 `session/projection` mux 帧(`{sessionId, key, value, seq}`——实时推送状态,绝不入日志;客户端按 seq 高者胜维护一个按会话的通用值仓)。载体不持有其他领域的知识(每个值在注册表内部已过其单元自己的 schema;协议 schema 对 `values`/`value` 保持宽松);loadOlder 页永不携带该块,未装注册表的组合则两个面都不提供。网关唯一自己注册的单元是 `imageLimits`:它在 prompt 准入时执行的 attachments 配置,以每次启动恒定的值发布(`apply` 保持状态引用不变,因此只靠基线携带、绝不产生变更帧),供客户端在提交前拒绝超限的加入并给上传入口标注上限;该单元仅在注册表与 attachments 服务同时组合时激活。
|
||||
|
||||
会话日志导出是宿主侧的下载面,不是 RPC:`GET /api/session.export?sessionId=…&includeDescendants=true` 流式返回一个 ZIP,其中每个文件都是会话存储工件的逐字原文(持久化后端的 `readRaw`——按物理编码解码的确切持久化字节,绝非从解析后事件重建),根会话放在其原始基础文件名下,每个子代理后代放在 `subagents/<id>/` 下,每个被任何包含的日志引用的图片放在 `media/<attachmentId>.<ext>` 下(从附件存储读取并校验;共享图片只出现一次)。每个实时根会话或后代都会在读取原始工件前立即通过权威的 `SessionStore.flush` 持久性屏障;冷会话没有需要 flush 的内存工作。压缩在宿主侧使用 fflate 流式 Zip API 和已验证的 `sessionExportCompressionLevel` 0–9(默认 6),使部署可以在 CPU/延迟与归档大小之间取舍;响应边生成边分块写出,宿主从不把整个归档放进单个缓冲区。响应队列达到 64 KiB 字节高水位后,生产会等待 Consumer pull 恢复正容量;fflate 的同步回调最多只会让该界限多出一次有界输入 push 的输出。请求中止或响应 body 取消会停止血缘与工件工作、终止活跃压缩器,并继续按取消传播,而不会变成 HTTP 500。它要求同时挂载持久化、session-query 与附件服务:任一缺失应答 500,持久化后端不提供每会话原始工件时应答 501,根会话缺失时应答 404,后代缺少存储工件或引用的图片无法读取则整个流失败(fail-loud,绝不静默少导出)。端点由传输层挂载,`ApiProxy.downloads.sessionLog` 实现它。
|
||||
|
||||
|
||||
@@ -85,6 +85,7 @@ import type { ApprovalOutcome, ApprovalRequestId } from '@deepseek-ai/dsh-user-a
|
||||
// `ctx.get('approval')` without a value dependency on the seam (optional composition).
|
||||
import type {} from '@deepseek-ai/dsh-user-approval'
|
||||
import { approvalResponsePayloadSchema } from './api/approvals.schema.ts'
|
||||
import { imageLimitsProjectionSchema } from './api/sessions.schema.ts'
|
||||
import { questionResponsePayloadSchema } from './api/questions.schema.ts'
|
||||
import type { ClientResponse, RpcError, RpcReceipt, RpcRequest, RpcResponse } from './api/rpc.ts'
|
||||
import { RpcId } from './api/rpc.ts'
|
||||
@@ -1227,6 +1228,30 @@ export function createApiProxy(ctx: Context, defaults: ApiProxyDefaults): ApiPro
|
||||
})
|
||||
})
|
||||
|
||||
// The imageLimits projection unit: the attachments config this proxy
|
||||
// enforces at prompt admission, constant per host boot. `apply` keeps the
|
||||
// same state reference for every event, so no change frames are ever
|
||||
// pushed — baselines alone carry the value — and clients pre-check intake
|
||||
// and label upload affordances from it. Registered here, not in the
|
||||
// attachment Service Definition: dsh-llm depends on dsh-attachment, so the
|
||||
// seam package cannot reference the projection registry without a cycle,
|
||||
// and the per-message rules the value describes are this proxy's own
|
||||
// admission checks. The child activates only while both seams are composed.
|
||||
// `view` reading the live service instead of the (null) state is sanctioned
|
||||
// exactly for boot-constant units: the value cannot change within a process
|
||||
// lifetime, so the fold stays observationally pure, and a stale persisted
|
||||
// cache row re-viewing to the current config is the correct outcome.
|
||||
ctx.inject(['sessionProjections', 'attachments'], (projectionCtx) => {
|
||||
projectionCtx.sessionProjections.register<'imageLimits', null>({
|
||||
key: 'imageLimits',
|
||||
schema: imageLimitsProjectionSchema,
|
||||
init: () => null,
|
||||
apply: state => state,
|
||||
view: () => projectionCtx.attachments.imageLimits,
|
||||
stateVersion: 1,
|
||||
})
|
||||
})
|
||||
|
||||
/** Project both durable inbox lists, optionally including the splice currently being emitted. */
|
||||
const queueItems = (
|
||||
agent: Agent,
|
||||
|
||||
@@ -15,7 +15,7 @@ import type {
|
||||
ModelReasoningEffort, ModelSelection, SessionProjectionsBlock, SessionSearchItem, SessionSummary,
|
||||
} from './sessions.ts'
|
||||
import type { ToolEventView } from './events.ts'
|
||||
import type { AttachmentIdType, ImageAttachmentRef } from '@deepseek-ai/dsh-attachment'
|
||||
import type { AttachmentIdType, ImageAttachmentLimits, ImageAttachmentRef } from '@deepseek-ai/dsh-attachment'
|
||||
import type { WorkspaceId } from './workspace.ts'
|
||||
import {
|
||||
SESSION_SEARCH_RESULT_LIMIT,
|
||||
@@ -213,7 +213,20 @@ export const sessionProjectionsBlockSchema = z.object({
|
||||
// -1 = empty log (the lastSeq convention of session/subscribed).
|
||||
asOfSeq: z.number().int().min(-1),
|
||||
values: z.record(z.string(), z.unknown()),
|
||||
}) as unknown as z.ZodType<SessionProjectionsBlock>
|
||||
}) as unknown as z.ZodType<Wire<SessionProjectionsBlock>>
|
||||
|
||||
/**
|
||||
* imageLimits projection unit schema (host-side view validation). zod widens
|
||||
* `readonly ImageMediaType[]` to `string[]`; on the JSON wire the two
|
||||
* serialize identically, so the cast records exactly that widening.
|
||||
*/
|
||||
export const imageLimitsProjectionSchema = z.object({
|
||||
maxImageBytes: z.number().int().positive(),
|
||||
maxImagesPerMessage: z.number().int().positive(),
|
||||
maxMessageImageBytes: z.number().int().positive(),
|
||||
maxImagePixels: z.number().int().positive(),
|
||||
mediaTypes: z.array(z.string()),
|
||||
}) as unknown as z.ZodType<ImageAttachmentLimits>
|
||||
|
||||
/** session.history response value (projections rides the tail page only). */
|
||||
export const sessionHistoryValueSchema: z.ZodType<Wire<ResponseValue<'session.history'>>> = z.object({
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
*/
|
||||
|
||||
import type { MessageId } from '@deepseek-ai/dsh-llm/brand'
|
||||
import type { AttachmentIdType, ImageAttachmentRef, ImageMediaType } from '@deepseek-ai/dsh-attachment'
|
||||
import type { AttachmentIdType, ImageAttachmentLimits, ImageAttachmentRef, ImageMediaType } from '@deepseek-ai/dsh-attachment'
|
||||
import type { ContentBlock } from '@deepseek-ai/dsh-llm/types'
|
||||
import type { SessionEvent, SessionId } from '@deepseek-ai/dsh-session/types'
|
||||
// The pure-type outlet: api/ is browser-importable, and the package root's
|
||||
@@ -15,6 +15,19 @@ import type { RpcId, RpcRequest, RpcResponse } from './rpc.ts'
|
||||
import type { ToolEventView } from './events.ts'
|
||||
import type { WorkspaceId } from './workspace.ts'
|
||||
|
||||
declare module '@deepseek-ai/dsh-session-projection/types' {
|
||||
interface SessionProjectionMap {
|
||||
/**
|
||||
* The deployment's image-intake limits: the attachments service's config
|
||||
* as this proxy enforces it at prompt admission, constant per host boot.
|
||||
* Clients pre-check count and bytes at intake and show the limits in
|
||||
* upload affordances. Key absence means no attachment service is
|
||||
* composed — clients skip the pre-check and let the host answer.
|
||||
*/
|
||||
imageLimits: ImageAttachmentLimits
|
||||
}
|
||||
}
|
||||
|
||||
declare module '@deepseek-ai/dsh-llm' {
|
||||
interface MessageSourceMap {
|
||||
/**
|
||||
|
||||
@@ -11,6 +11,7 @@ import { describe, expect, it } from 'vitest'
|
||||
import { Context } from '@deepseek-ai/cordis'
|
||||
import { z } from 'zod'
|
||||
import AgentRegistry, { Inbox } from '@deepseek-ai/dsh-agent'
|
||||
import { AttachmentStore } from '@deepseek-ai/dsh-attachment'
|
||||
import type { Agent } from '@deepseek-ai/dsh-agent'
|
||||
import { createUserMessage } from '@deepseek-ai/dsh-llm'
|
||||
import SessionStore, { SessionId } from '@deepseek-ai/dsh-session'
|
||||
@@ -86,6 +87,51 @@ describe('session.history projections block', () => {
|
||||
expect(events.at(-1)?.event.seq).toBe(projections?.asOfSeq)
|
||||
})
|
||||
|
||||
it('publishes the attachments imageLimits as a constant unit while both seams are composed', async () => {
|
||||
const { ctx, session } = await harness(true)
|
||||
const limits = {
|
||||
maxImageBytes: 5 * 1024 * 1024,
|
||||
maxImagesPerMessage: 20,
|
||||
maxMessageImageBytes: 100 * 1024 * 1024,
|
||||
maxImagePixels: 40_000_000,
|
||||
mediaTypes: ['image/png'] as const,
|
||||
}
|
||||
await ctx.plugin(class extends AttachmentStore {
|
||||
readonly imageLimits = limits
|
||||
validateImage(): Promise<void> { return Promise.resolve() }
|
||||
saveImage(): Promise<never> { return Promise.reject(new Error('unused')) }
|
||||
readImage(): Promise<never> { return Promise.reject(new Error('unused')) }
|
||||
})
|
||||
const gateway = api(ctx)
|
||||
seedMessages(session, 2)
|
||||
const response = await gateway.sessions.history(request({ sessionId: session.id }))
|
||||
if (!response.result.ok) throw new Error('history failed')
|
||||
expect(response.result.value.projections?.values['imageLimits']).toEqual(limits)
|
||||
// Constant unit: appending events must never broadcast an imageLimits frame.
|
||||
await new Promise(resolve => setTimeout(resolve, 0))
|
||||
const abort = new AbortController()
|
||||
const stream = gateway.events.mux({ rpcId: RpcId('t-limits-mux'), payload: {} }, abort.signal)
|
||||
const frames: MuxFrame[] = []
|
||||
const drained = (async () => {
|
||||
for await (const envelope of stream) {
|
||||
frames.push(envelope.payload)
|
||||
if (frames.some(f => f.type === 'session/event')) abort.abort()
|
||||
}
|
||||
})().catch(() => {})
|
||||
seedMessages(session, 1)
|
||||
await drained
|
||||
expect(frames.some(f => f.type === 'session/projection' && f.key === 'imageLimits')).toBe(false)
|
||||
})
|
||||
|
||||
it('leaves the imageLimits key absent while no attachment service is composed', async () => {
|
||||
const { ctx, session } = await harness(true)
|
||||
seedMessages(session, 1)
|
||||
const response = await api(ctx).sessions.history(request({ sessionId: session.id }))
|
||||
if (!response.result.ok) throw new Error('history failed')
|
||||
expect(response.result.value.projections).toBeDefined()
|
||||
expect('imageLimits' in (response.result.value.projections?.values ?? {})).toBe(false)
|
||||
})
|
||||
|
||||
it('never carries the block on loadOlder pages (beforeSeq present)', async () => {
|
||||
const { ctx, session } = await harness(true)
|
||||
ctx.sessionProjections.register(lastUserUnit())
|
||||
|
||||
6
packages/host/plugin-inventory/README.i18n.yaml
Normal file
6
packages/host/plugin-inventory/README.i18n.yaml
Normal file
@@ -0,0 +1,6 @@
|
||||
# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each
|
||||
# side as of the last confirmed-consistent state. Both languages carry equal authority;
|
||||
# after editing either side, bring the other along and re-record with:
|
||||
# pnpm run verify-translation-pairing --write packages/host/plugin-inventory/README.md
|
||||
README.md: 23fbf07d7900ecc881f81b5da3f8cbe6a45669de
|
||||
README.zh.md: 87058cde595b83e980b8f3cec4192e6099b8d9ea
|
||||
22
packages/host/plugin-inventory/README.md
Normal file
22
packages/host/plugin-inventory/README.md
Normal file
@@ -0,0 +1,22 @@
|
||||
# @deepseek-ai/dsh-host-plugin-inventory
|
||||
|
||||
English | [中文](README.zh.md)
|
||||
|
||||
Read-only Host projection of the current Cordis Loader tree. `PluginInventoryService` registers the `pluginInventory` service and publishes one generated direct Remote, `pluginInventory/list`. Every call reads `ctx.loader.entries()` directly, skips structural group rows, and returns the remaining entries in Loader order with only their Loader entry id, module specifier, effective enablement, and current root Fiber phase.
|
||||
|
||||
The phase is `pending`, `loading`, `active`, `failed`, or `unloading`; it is `null` when the entry has no live root Fiber. The snapshot is intentionally point-in-time: Loader remains the sole lifecycle authority, while this package owns no cache, history, provenance model, event stream, or mutation path. Its public payload types live under `./types`, and TypeRT generates the Host and Client Remote artifacts exposed by `./typert` and `./remote`.
|
||||
|
||||
The service is Remote-only and deliberately declares no same-process Cordis `Context` merge. Client packages consume it through the explicit [`api-remotes`](../../api/remotes/README.md) assembly rather than importing the Host implementation.
|
||||
|
||||
## Model Experience
|
||||
|
||||
None, as this Host-only inventory projection registers no prompt, tool, message, or provider request.
|
||||
|
||||
#### KV Cache effect
|
||||
|
||||
None; this package never assembles model input.
|
||||
|
||||
## Known Limitations and Deferred Work
|
||||
|
||||
- **Point-in-time state only** — the result contains no durable failure history or subscription; a missing root Fiber is reported as `null`, regardless of why no live root exists.
|
||||
- **No provenance or mutation** — the service does not identify which bundle, profile, or override introduced an entry, and it cannot enable, disable, add, or remove plugins.
|
||||
22
packages/host/plugin-inventory/README.zh.md
Normal file
22
packages/host/plugin-inventory/README.zh.md
Normal file
@@ -0,0 +1,22 @@
|
||||
# @deepseek-ai/dsh-host-plugin-inventory
|
||||
|
||||
[English](README.md) | 中文
|
||||
|
||||
当前 Cordis Loader 树的只读 Host 投影。`PluginInventoryService` 注册 `pluginInventory` 服务,并发布一个由 TypeRT 生成的直接 Remote:`pluginInventory/list`。每次调用都直接读取 `ctx.loader.entries()`,跳过结构性的 group 行,再按 Loader 顺序返回其余条目,并且只包含 Loader 条目 id、模块标识、有效启用状态与当前根 Fiber 阶段。
|
||||
|
||||
阶段为 `pending`、`loading`、`active`、`failed` 或 `unloading`;条目没有存活的根 Fiber 时则为 `null`。该快照刻意只表示调用当下:Loader 仍是唯一的生命周期权威,本包不拥有缓存、历史、来源模型、事件流或修改路径。公开 payload 类型位于 `./types`,TypeRT 生成由 `./typert` 与 `./remote` 导出的 Host 和 Client Remote 产物。
|
||||
|
||||
该服务仅供 Remote 使用,刻意不声明同进程 Cordis `Context` merge。Client 包通过显式的 [`api-remotes`](../../api/remotes/README.md) 组合消费它,而不导入 Host 实现。
|
||||
|
||||
## 模型体验
|
||||
|
||||
无,因为这个仅限 Host 的清单投影不注册提示词、工具、消息或提供方请求。
|
||||
|
||||
#### KV Cache 影响
|
||||
|
||||
无;本包从不组装模型输入。
|
||||
|
||||
## 已知限制与暂缓事项
|
||||
|
||||
- **仅表示调用当下** —— 结果不包含持久的失败历史或订阅;只要不存在存活的根 Fiber,就会报告 `null`,而不区分其原因。
|
||||
- **无来源与修改能力** —— 服务不识别条目由哪个 bundle、profile 或 override 引入,也不能启用、停用、添加或移除插件。
|
||||
68
packages/host/plugin-inventory/package.json
Normal file
68
packages/host/plugin-inventory/package.json
Normal file
@@ -0,0 +1,68 @@
|
||||
{
|
||||
"name": "@deepseek-ai/dsh-host-plugin-inventory",
|
||||
"description": "Read-only Remote projection of current Cordis Loader plugin state",
|
||||
"version": "0.0.1-rc.2",
|
||||
"publishConfig": {
|
||||
"access": "restricted"
|
||||
},
|
||||
"repository": {
|
||||
"type": "git",
|
||||
"url": "git+https://github.com/deepseek-ai/deepseek-harness.git",
|
||||
"directory": "packages/host/plugin-inventory"
|
||||
},
|
||||
"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"
|
||||
},
|
||||
"./types": {
|
||||
"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"
|
||||
},
|
||||
"files": [
|
||||
"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"
|
||||
],
|
||||
"license": "BSD-3-Clause",
|
||||
"dependencies": {
|
||||
"zod": "^4.4.3"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@deepseek-ai/cordis-plugin-loader": "workspace:^",
|
||||
"@deepseek-ai/dsh-brand": "workspace:^",
|
||||
"@deepseek-ai/dsh-invariants": "workspace:^",
|
||||
"@deepseek-ai/dsh-type-meta": "workspace:^",
|
||||
"@deepseek-ai/cordis": "workspace:^"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@deepseek-ai/cordis-plugin-loader": "workspace:^",
|
||||
"@deepseek-ai/dsh-brand": "workspace:^",
|
||||
"@deepseek-ai/dsh-invariants": "workspace:^",
|
||||
"@deepseek-ai/dsh-type-meta": "workspace:^",
|
||||
"@deepseek-ai/cordis": "workspace:^"
|
||||
}
|
||||
}
|
||||
72
packages/host/plugin-inventory/src/index.ts
Normal file
72
packages/host/plugin-inventory/src/index.ts
Normal file
@@ -0,0 +1,72 @@
|
||||
/** Read-only projection of the current Cordis Loader plugin entries. */
|
||||
|
||||
import type { Context, FiberState } from '@deepseek-ai/cordis'
|
||||
import type {} from '@deepseek-ai/cordis-plugin-loader'
|
||||
import { GatewayService, Remote } from '@deepseek-ai/dsh-type-meta'
|
||||
// TypeRT-generated ./typert and ./remote artifacts import Zod at runtime.
|
||||
import type {} from 'zod'
|
||||
import type {
|
||||
PluginEntryId,
|
||||
PluginFiberPhase,
|
||||
PluginInventoryEntry,
|
||||
PluginInventorySnapshot,
|
||||
} from './types.ts'
|
||||
|
||||
export type * from './types.ts'
|
||||
|
||||
/** Brand an existing Loader-tree entry id at the owning boundary. */
|
||||
function pluginEntryId(value: string): PluginEntryId {
|
||||
return value as PluginEntryId
|
||||
}
|
||||
|
||||
/** Runtime mirror: FiberState is a cross-package const enum. */
|
||||
const FIBER_STATE = {
|
||||
PENDING: 0 as FiberState.PENDING,
|
||||
LOADING: 1 as FiberState.LOADING,
|
||||
ACTIVE: 2 as FiberState.ACTIVE,
|
||||
FAILED: 3 as FiberState.FAILED,
|
||||
DISPOSED: 4 as FiberState.DISPOSED,
|
||||
UNLOADING: 5 as FiberState.UNLOADING,
|
||||
} as const
|
||||
|
||||
/** Complete public projection of Cordis Fiber states. */
|
||||
const FIBER_PHASE = {
|
||||
[FIBER_STATE.PENDING]: 'pending',
|
||||
[FIBER_STATE.LOADING]: 'loading',
|
||||
[FIBER_STATE.ACTIVE]: 'active',
|
||||
[FIBER_STATE.FAILED]: 'failed',
|
||||
[FIBER_STATE.DISPOSED]: null,
|
||||
[FIBER_STATE.UNLOADING]: 'unloading',
|
||||
} as const satisfies Record<FiberState, PluginFiberPhase>
|
||||
|
||||
/** Remote-only service exposing the Loader's current non-group entry state. */
|
||||
export class PluginInventoryService extends GatewayService {
|
||||
static inject = ['loader']
|
||||
|
||||
constructor(ctx: Context) {
|
||||
super(ctx, 'pluginInventory')
|
||||
}
|
||||
|
||||
/**
|
||||
* Read the Loader directly on every call. Cordis's internal plugin/status
|
||||
* events already maintain Entry.fiber and Fiber.state, so a second cache
|
||||
* would only add another lifecycle truth to keep synchronized.
|
||||
* @returns Current non-group Loader entries in Loader order.
|
||||
*/
|
||||
@Remote('list')
|
||||
list(): PluginInventorySnapshot {
|
||||
const entries: PluginInventoryEntry[] = []
|
||||
for (const entry of this.ctx.loader.entries()) {
|
||||
if (entry.options.group) continue
|
||||
entries.push({
|
||||
entryId: pluginEntryId(entry.id),
|
||||
moduleName: entry.options.name,
|
||||
enabled: !entry.disabled,
|
||||
fiberPhase: entry.fiber === undefined ? null : FIBER_PHASE[entry.fiber.state],
|
||||
})
|
||||
}
|
||||
return { entries }
|
||||
}
|
||||
}
|
||||
|
||||
export default PluginInventoryService
|
||||
20
packages/host/plugin-inventory/src/invariant.ts
Normal file
20
packages/host/plugin-inventory/src/invariant.ts
Normal file
@@ -0,0 +1,20 @@
|
||||
/** Package-owned invariant companion. @module @deepseek-ai/dsh-host-plugin-inventory/invariant */
|
||||
|
||||
/* jscpd:ignore-start */
|
||||
import type { Context } from '@deepseek-ai/cordis'
|
||||
import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants'
|
||||
|
||||
const PACKAGE_NAME = '@deepseek-ai/dsh-host-plugin-inventory'
|
||||
|
||||
/** Cordis companion plugin name. */
|
||||
export const name = 'host-plugin-inventory-invariant'
|
||||
/** Service required before the companion can reserve package ownership. */
|
||||
export const inject = ['invariants']
|
||||
|
||||
/** No runtime invariant: every snapshot is projected directly from Loader-owned state. */
|
||||
const install: InvariantInstaller = () => {}
|
||||
|
||||
/** Register this package's invariant companion. */
|
||||
export const apply = (ctx: Context): Promise<() => void> =>
|
||||
Promise.resolve(ctx.invariants.register(PACKAGE_NAME, install))
|
||||
/* jscpd:ignore-end */
|
||||
28
packages/host/plugin-inventory/src/types.ts
Normal file
28
packages/host/plugin-inventory/src/types.ts
Normal file
@@ -0,0 +1,28 @@
|
||||
import type { Branded } from '@deepseek-ai/dsh-brand'
|
||||
|
||||
/** Stable Loader-tree identity of one configured plugin entry. */
|
||||
export type PluginEntryId = Branded<'PluginEntryId'>
|
||||
|
||||
/** Lifecycle state of an entry's root Fiber, or null when it has no live root Fiber. */
|
||||
export type PluginFiberPhase =
|
||||
| 'pending'
|
||||
| 'loading'
|
||||
| 'active'
|
||||
| 'failed'
|
||||
| 'unloading'
|
||||
| null
|
||||
|
||||
/** One non-group Loader entry exposed to trusted clients. */
|
||||
export interface PluginInventoryEntry {
|
||||
readonly entryId: PluginEntryId
|
||||
/** Exact module specifier imported by the Loader entry. */
|
||||
readonly moduleName: string
|
||||
/** Effective Loader enablement, including disabled ancestor groups. */
|
||||
readonly enabled: boolean
|
||||
readonly fiberPhase: PluginFiberPhase
|
||||
}
|
||||
|
||||
/** Point-in-time inventory returned by the plugin inventory Remote. */
|
||||
export interface PluginInventorySnapshot {
|
||||
readonly entries: readonly PluginInventoryEntry[]
|
||||
}
|
||||
16
packages/host/plugin-inventory/tests/invariant.spec.ts
Normal file
16
packages/host/plugin-inventory/tests/invariant.spec.ts
Normal file
@@ -0,0 +1,16 @@
|
||||
import { Context } from '@deepseek-ai/cordis'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import InvariantService from '@deepseek-ai/dsh-invariants'
|
||||
import * as PluginInventoryInvariant from '../src/invariant.ts'
|
||||
|
||||
describe('plugin-inventory invariant companion', () => {
|
||||
it('registers the package-owned empty installer', async () => {
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(InvariantService, { enabled: true })
|
||||
const fiber = ctx.plugin(PluginInventoryInvariant)
|
||||
await expect(fiber.await()).resolves.toBeDefined()
|
||||
await fiber.dispose()
|
||||
await expect(ctx.plugin(PluginInventoryInvariant).await()).resolves.toBeDefined()
|
||||
await ctx.fiber.dispose()
|
||||
})
|
||||
})
|
||||
89
packages/host/plugin-inventory/tests/inventory.spec.ts
Normal file
89
packages/host/plugin-inventory/tests/inventory.spec.ts
Normal file
@@ -0,0 +1,89 @@
|
||||
import { afterEach, describe, expect, it } from 'vitest'
|
||||
import { Context, type Plugin } from '@deepseek-ai/cordis'
|
||||
import Loader from '@deepseek-ai/cordis-plugin-loader'
|
||||
import { remoteMethods } from '@deepseek-ai/dsh-type-meta'
|
||||
import PluginInventoryService from '../src/index.ts'
|
||||
|
||||
const contexts: Context[] = []
|
||||
|
||||
afterEach(async () => {
|
||||
await Promise.all(contexts.splice(0).map(ctx => ctx.fiber.dispose()))
|
||||
})
|
||||
|
||||
const activePlugin: Plugin.Function = () => {}
|
||||
const pendingPlugin: Plugin.Object = {
|
||||
inject: ['neverReady'],
|
||||
apply() {},
|
||||
}
|
||||
|
||||
async function harness(): Promise<{
|
||||
ctx: Context
|
||||
inventory: PluginInventoryService
|
||||
}> {
|
||||
const ctx = new Context()
|
||||
contexts.push(ctx)
|
||||
await ctx.plugin(Loader)
|
||||
ctx.loader.builtins.active = activePlugin
|
||||
ctx.loader.builtins.pending = pendingPlugin
|
||||
await ctx.plugin(PluginInventoryService)
|
||||
const inventory = ctx.get('pluginInventory') as PluginInventoryService
|
||||
return { ctx, inventory }
|
||||
}
|
||||
|
||||
describe('PluginInventoryService', () => {
|
||||
it('publishes one direct list method under the pluginInventory namespace', async () => {
|
||||
const { inventory } = await harness()
|
||||
expect(inventory.typertGateway).toMatchObject({
|
||||
serviceKey: 'pluginInventory',
|
||||
namespace: 'pluginInventory',
|
||||
})
|
||||
expect(remoteMethods(inventory)).toEqual([
|
||||
{ method: 'list', invocation: { kind: 'direct' } },
|
||||
])
|
||||
})
|
||||
|
||||
it('projects current non-group Loader entries without a second cache', async () => {
|
||||
const { ctx, inventory } = await harness()
|
||||
const activeId = await ctx.loader.create({ name: 'cordis:active' })
|
||||
const pendingId = await ctx.loader.create({ name: 'cordis:pending' })
|
||||
const disabledId = await ctx.loader.create({
|
||||
name: 'cordis:not-installed',
|
||||
disabled: true,
|
||||
})
|
||||
await ctx.loader.create({ name: 'cordis:active', group: true })
|
||||
|
||||
expect(inventory.list()).toEqual({
|
||||
entries: [
|
||||
{
|
||||
entryId: activeId,
|
||||
moduleName: 'cordis:active',
|
||||
enabled: true,
|
||||
fiberPhase: 'active',
|
||||
},
|
||||
{
|
||||
entryId: pendingId,
|
||||
moduleName: 'cordis:pending',
|
||||
enabled: true,
|
||||
fiberPhase: 'pending',
|
||||
},
|
||||
{
|
||||
entryId: disabledId,
|
||||
moduleName: 'cordis:not-installed',
|
||||
enabled: false,
|
||||
fiberPhase: null,
|
||||
},
|
||||
],
|
||||
})
|
||||
|
||||
await ctx.loader.update(activeId, { disabled: true })
|
||||
expect(inventory.list().entries.find(entry => entry.entryId === activeId)).toEqual({
|
||||
entryId: activeId,
|
||||
moduleName: 'cordis:active',
|
||||
enabled: false,
|
||||
fiberPhase: null,
|
||||
})
|
||||
|
||||
await ctx.loader.remove(pendingId)
|
||||
expect(inventory.list().entries.some(entry => entry.entryId === pendingId)).toBe(false)
|
||||
})
|
||||
})
|
||||
27
packages/host/plugin-inventory/tsconfig.json
Normal file
27
packages/host/plugin-inventory/tsconfig.json
Normal file
@@ -0,0 +1,27 @@
|
||||
{
|
||||
"extends": "../../../tsconfig.base.json",
|
||||
"compilerOptions": {
|
||||
"rootDir": "src",
|
||||
"outDir": "lib/types"
|
||||
},
|
||||
"include": [
|
||||
"src"
|
||||
],
|
||||
"references": [
|
||||
{
|
||||
"path": "../../../vendor/cordis"
|
||||
},
|
||||
{
|
||||
"path": "../../../vendor/loader"
|
||||
},
|
||||
{
|
||||
"path": "../../util/brand"
|
||||
},
|
||||
{
|
||||
"path": "../../typert/type-meta"
|
||||
},
|
||||
{
|
||||
"path": "../../support/invariants"
|
||||
}
|
||||
]
|
||||
}
|
||||
Reference in New Issue
Block a user