refactor(experimental): share the dsh-action parser and drop the cross-experiment coupling

The fenced-block parser moves to the zero-dependency package
@deepseek-ai/dsh-util-action-block, which the client bundle inlines;
Personalized pages and Plugin recommendations both read answers through
it and no longer import or edit each other. Personalized pages yields:
a page block in an answer or turn that also carries another action's
block is logged but never offered, and a later other-action block
retracts this turn's undecided page card. Plugin recommendations never
yields and keeps one plugin card per turn of its own.
This commit is contained in:
ZiyaZhang committed 2026-10-21 13:08:06 -07:00
1 parent d4800ceca2
commit 2eb1b89cac
45 files changed
+494 -149

No files matched your search

+2 -1
View File
@@ -1042,7 +1042,7 @@ export interface Config {
## `@deepseek-ai/dsh-experimental-creation-recommendations`
- `refs`: `z` (`zod`)
- `source`: [`packages/experimental/creation-recommendations/src/index.ts:34`](../packages/experimental/creation-recommendations/src/index.ts)
- `source`: [`packages/experimental/creation-recommendations/src/index.ts:33`](../packages/experimental/creation-recommendations/src/index.ts)
```ts config-catalog
/** Validated card limits after schema defaults. */
@@ -4918,6 +4918,7 @@ Imported as libraries by other packages; a `cordis.yml` cannot load them.
| `@deepseek-ai/dsh-typert-generator` | — | [`packages/typert/generator/src/index.ts`](../packages/typert/generator/src/index.ts) |
| `@deepseek-ai/dsh-typert-protocol` | — | [`packages/typert/protocol/src/index.ts`](../packages/typert/protocol/src/index.ts) |
| `@deepseek-ai/dsh-typert-registry` | — | [`packages/typert/registry/src/index.ts`](../packages/typert/registry/src/index.ts) |
| `@deepseek-ai/dsh-util-action-block` | — | [`packages/util/action-block/src/index.ts`](../packages/util/action-block/src/index.ts) |
| `@deepseek-ai/dsh-util-code-language` | — | [`packages/util/code-language/src/index.ts`](../packages/util/code-language/src/index.ts) |
| `@deepseek-ai/dsh-util-crypto` | — | [`packages/util/crypto/src/index.ts`](../packages/util/crypto/src/index.ts) |
| `@deepseek-ai/dsh-util-time` | — | [`packages/util/time/src/index.ts`](../packages/util/time/src/index.ts) |
+2 -1
View File
@@ -1044,7 +1044,7 @@ export interface Config {
## `@deepseek-ai/dsh-experimental-creation-recommendations`
- `refs`: `z` (`zod`)
- `source`: [`packages/experimental/creation-recommendations/src/index.ts:34`](../packages/experimental/creation-recommendations/src/index.ts)
- `source`: [`packages/experimental/creation-recommendations/src/index.ts:33`](../packages/experimental/creation-recommendations/src/index.ts)
```ts config-catalog
/** Validated card limits after schema defaults. */
@@ -4920,6 +4920,7 @@ export interface Config {
| `@deepseek-ai/dsh-typert-generator` | — | [`packages/typert/generator/src/index.ts`](../packages/typert/generator/src/index.ts) |
| `@deepseek-ai/dsh-typert-protocol` | — | [`packages/typert/protocol/src/index.ts`](../packages/typert/protocol/src/index.ts) |
| `@deepseek-ai/dsh-typert-registry` | — | [`packages/typert/registry/src/index.ts`](../packages/typert/registry/src/index.ts) |
| `@deepseek-ai/dsh-util-action-block` | — | [`packages/util/action-block/src/index.ts`](../packages/util/action-block/src/index.ts) |
| `@deepseek-ai/dsh-util-code-language` | — | [`packages/util/code-language/src/index.ts`](../packages/util/code-language/src/index.ts) |
| `@deepseek-ai/dsh-util-crypto` | — | [`packages/util/crypto/src/index.ts`](../packages/util/crypto/src/index.ts) |
| `@deepseek-ai/dsh-util-time` | — | [`packages/util/time/src/index.ts`](../packages/util/time/src/index.ts) |
+2
View File
@@ -22,6 +22,7 @@ flowchart TD
pkg_plugin_detail["plugin-detail"]
pkg_plugin_market_index["plugin-market-index"]
pkg_timeout["timeout"]
pkg_util_action_block["util-action-block"]
pkg_util_code_language["util-code-language"]
pkg_util_crypto["util-crypto"]
pkg_util_time["util-time"]
@@ -1484,6 +1485,7 @@ flowchart TD
| [`plugin-detail`](../packages/util/plugin-detail) | `util` | — |
| [`plugin-market-index`](../packages/util/plugin-market-index) | `util` | — |
| [`timeout`](../packages/util/timeout) | `util` | — |
| [`util-action-block`](../packages/util/action-block) | `util` | — |
| [`util-code-language`](../packages/util/code-language) | `util` | — |
| [`util-crypto`](../packages/util/crypto) | `util` | — |
| [`util-time`](../packages/util/time) | `util` | — |
+2
View File
@@ -24,6 +24,7 @@ flowchart TD
pkg_plugin_detail["plugin-detail"]
pkg_plugin_market_index["plugin-market-index"]
pkg_timeout["timeout"]
pkg_util_action_block["util-action-block"]
pkg_util_code_language["util-code-language"]
pkg_util_crypto["util-crypto"]
pkg_util_time["util-time"]
@@ -1486,6 +1487,7 @@ flowchart TD
| [`plugin-detail`](../packages/util/plugin-detail) | `util` | — |
| [`plugin-market-index`](../packages/util/plugin-market-index) | `util` | — |
| [`timeout`](../packages/util/timeout) | `util` | — |
| [`util-action-block`](../packages/util/action-block) | `util` | — |
| [`util-code-language`](../packages/util/code-language) | `util` | — |
| [`util-crypto`](../packages/util/crypto) | `util` | — |
| [`util-time`](../packages/util/time) | `util` | — |
+1 -1
View File
@@ -82,7 +82,7 @@ function styleInjectionModule(
* Everything else under @deepseek-ai/* is either a module-table entry
* (external) or a leak the purity gate rejects.
*/
export const INLINE_SAFE = /^(?:@deepseek-ai\/dsh-(?:file-reference|session|llm|tools|brand|deque|output-retention|typert-protocol|util-crypto|util-values|util-workspace-path)(?:\/|$)|@deepseek-ai\/dsh-token-meter\/client$|@deepseek-ai\/dsh-native-command\/types$|@deepseek-ai\/dsh-host-open-in-app\/shared$|@deepseek-ai\/dsh-plugin-manager\/registry$|@deepseek-ai\/dsh-agent-preset-registry\/display$|@deepseek-ai\/dsh-api-workspace-controller\/default-workspace$|@deepseek-ai\/dsh-spill-policy\/notice$|@deepseek-ai\/dsh-experimental-creation-recommendations\/action-block$)/
export const INLINE_SAFE = /^(?:@deepseek-ai\/dsh-(?:file-reference|session|llm|tools|brand|deque|output-retention|typert-protocol|util-action-block|util-crypto|util-values|util-workspace-path)(?:\/|$)|@deepseek-ai\/dsh-token-meter\/client$|@deepseek-ai\/dsh-native-command\/types$|@deepseek-ai\/dsh-host-open-in-app\/shared$|@deepseek-ai\/dsh-plugin-manager\/registry$|@deepseek-ai\/dsh-agent-preset-registry\/display$|@deepseek-ai\/dsh-api-workspace-controller\/default-workspace$|@deepseek-ai\/dsh-spill-policy\/notice$)/
/**
* Vendored framework libraries: rescoped into @deepseek-ai, so the gate below
@@ -56,6 +56,7 @@
"@deepseek-ai/dsh-client-ui-primitives": "workspace:*",
"react": "^18.2.0",
"@deepseek-ai/dsh-experimental-creation-recommendations": "workspace:*",
"@deepseek-ai/dsh-util-action-block": "workspace:*",
"@deepseek-ai/dsh-client-store": "workspace:*",
"@deepseek-ai/dsh-typert-protocol": "workspace:*"
},
@@ -3,7 +3,7 @@ import type { CreationRecommendation } from '@deepseek-ai/dsh-experimental-creat
import type { ConversationNodeDefinition } from '@deepseek-ai/dsh-client-ui-conversation/client'
import type { ChatNode } from '@deepseek-ai/dsh-client-ui-chat/client'
import type { ToolCallId } from '@deepseek-ai/dsh-llm/brand'
import { actionBlocksOf } from '@deepseek-ai/dsh-experimental-creation-recommendations/action-block'
import { actionBlocksOf } from '@deepseek-ai/dsh-util-action-block'
declare module '@deepseek-ai/dsh-client-ui-chat/client' {
interface ChatNodeDataMap { 'creation-recommendation': CreationRecommendation }
@@ -44,6 +44,9 @@
{
"path": "../creation-recommendations"
},
{
"path": "../../util/action-block"
},
{
"path": "../../client/store"
},
@@ -61,7 +61,7 @@
"@deepseek-ai/dsh-client-ui-plugin-manager": "workspace:*",
"@deepseek-ai/dsh-typert-protocol": "workspace:*",
"@deepseek-ai/dsh-package-manifest": "workspace:*",
"@deepseek-ai/dsh-experimental-creation-recommendations": "workspace:*"
"@deepseek-ai/dsh-util-action-block": "workspace:*"
},
"files": [
"locale/*.json",
@@ -3,7 +3,7 @@ import type { PluginRecommendation } from '@deepseek-ai/dsh-experimental-plugin-
import type { ConversationNodeDefinition } from '@deepseek-ai/dsh-client-ui-conversation/client'
import type { ChatNode } from '@deepseek-ai/dsh-client-ui-chat/client'
import type { ToolCallId } from '@deepseek-ai/dsh-llm/brand'
import { actionBlocksOf } from '@deepseek-ai/dsh-experimental-creation-recommendations/action-block'
import { actionBlocksOf } from '@deepseek-ai/dsh-util-action-block'
declare module '@deepseek-ai/dsh-client-ui-chat/client' {
interface ChatNodeDataMap { 'plugin-recommendation': PluginRecommendation }
@@ -57,7 +57,7 @@
"path": "../../util/package-manifest"
},
{
"path": "../creation-recommendations"
"path": "../../util/action-block"
}
]
}
@@ -21,8 +21,8 @@
en: 58a58f545ad72b48
zh: 996549efff61b613
/deepseek-ai-dsh-experimental-creation-recommendations/understand-the-implementation:
en: ae8a228a4d31f288
zh: a77c644246a97f21
en: ce3d1a6129836750
zh: cb7688f46c1b2bd7
/deepseek-ai-dsh-experimental-creation-recommendations/further-exploration:
en: 764927dcbad0e080
zh: 9d14cb335a2fb54c
@@ -61,7 +61,7 @@ The next naturally occurring model request receives the latest unreported status
The Host entry is a function plugin. Its `apply` assembles the internal `creationRecommendations` Remote service with `ctx.plugin`. That service owns feature state, effect lifetimes and typed button calls; it is not a separately configurable or replaceable YAML entry, and adds no service to `dsh-base`. Disabling the feature disposes its internal service, tools and listeners and drains its owned asynchronous work. This follows the existing `command-feedback` composition of `sessionFeedback`.
The plugin adds no model tool. A proposal is one fenced `dsh-action` JSON object at the end of the final answer; the Session projection reads the last valid block of an assistant message, identifies it by the message id, and keeps one card per turn across both card actions: the last valid page or official-plugin block of an answer is its card, the first answer with a card holds the turn, and a page block that is not the card is logged as suppressed and never offered. A proposal written while `queuedMessageLimit` or more user messages are queued is logged but marked suppressed and never offered. The chat hides fences in the `dsh-` language namespace from rendered answers; the Trajectory view and the session log keep the answer as written. This experiment has no action-card service, public registry or Loader entry; other action types share the block format once a second consumer registers one.
The plugin adds no model tool. A proposal is one fenced `dsh-action` JSON object at the end of the final answer; the Session projection reads the last valid block of an assistant message, identifies it by the message id, and keeps one card per turn and yields to every other action: a page block in an answer that also carries another action's block, or in a turn another card already holds, is logged as suppressed and never offered, and another action's block later in the turn retracts an undecided page card. The projection reads blocks through `@deepseek-ai/dsh-util-action-block` and knows no other experiment's schema. A proposal written while `queuedMessageLimit` or more user messages are queued is logged but marked suppressed and never offered. The chat hides fences in the `dsh-` language namespace from rendered answers; the Trajectory view and the session log keep the answer as written. This experiment has no action-card service, public registry or Loader entry; other action types share the block format once a second consumer registers one.
The block format carries typed proposals and defines click-to-start semantics; each action supplies its fields and Skill entry. `personalized-pages` owns eligibility, duplicate checks, minimal scope, placement and content preparation. It allows implementation details needed for the agreed behavior, such as input validation and empty states; independent operations, sources, automation or broader access require a basis in the request or agreed context.
@@ -61,7 +61,7 @@ kind: "package-bundle"
Host 入口是普通函数插件,在 `apply` 内通过 `ctx.plugin` 组装内部 `creationRecommendations` Remote service。该 service 负责本功能的状态、effect 生命周期和类型化按钮通信,不作为可替换能力单独暴露为 YAML 条目;`dsh-base` 不增加 service。关闭功能插件时一并卸载内部 service、工具和监听,并清理其负责的异步工作。此结构沿用 `command-feedback` 组装 `sessionFeedback` 的现有方式。
插件不新增模型工具。提案是最终回答末尾的一个 `dsh-action` 围栏 JSON 对象;Session 投影读取助手消息里最后一个合法的块,用消息 id 标识它,两种卡片动作合计每轮一张:一条回答以其中最后一个合法的页面块或官方插件块为准,本轮第一条带卡片的回答占用这一轮,不是本轮卡片的页面块会被记录并标为“未展示”。用户排队的消息达到 `queuedMessageLimit` 条及以上时写出的提案会被记录但标为“未展示”,不会出卡。聊天视图隐藏 `dsh-` 语言前缀的围栏;轨迹视图和会话日志保留原始回答。本实验没有卡片 service、公共注册中心或独立 Loader 条目;第二个消费者注册后,其他动作类型共用同一块格式。
插件不新增模型工具。提案是最终回答末尾的一个 `dsh-action` 围栏 JSON 对象;Session 投影读取助手消息里最后一个合法的块,用消息 id 标识它,每轮一张卡,并对其他动作让位:同一条回答里还带着别的动作块、或本轮已被别的卡占用时,页面块会被记录并标为“未展示”;本轮稍后出现别的动作块时,尚未处理的页面卡也会被收回。投影通过 `@deepseek-ai/dsh-util-action-block` 读块,不认识其他实验的 schema。用户排队的消息达到 `queuedMessageLimit` 条及以上时写出的提案会被记录但标为“未展示”,不会出卡。聊天视图隐藏 `dsh-` 语言前缀的围栏;轨迹视图和会话日志保留原始回答。本实验没有卡片 service、公共注册中心或独立 Loader 条目;第二个消费者注册后,其他动作类型共用同一块格式。
块格式承载类型化提案,定义点击后执行的语义;各动作提供自己的字段和 Skill 入口。`personalized-pages` 负责适用条件、查重、最小范围、选位和内容准备。它允许补齐实现已确认行为所需的细节,例如输入校验和空状态;新增独立操作、数据源、自动任务或访问范围,需要用户请求或已确认上下文的依据。
@@ -25,10 +25,6 @@
"types": "./lib/types/types.d.ts",
"default": "./lib/types/types.js"
},
"./action-block": {
"types": "./lib/types/action-block.d.ts",
"default": "./lib/types/action-block.js"
},
"./typert": {
"types": "./lib/typert.host.d.ts",
"default": "./lib/typert.host.js"
@@ -71,6 +67,7 @@
"@deepseek-ai/dsh-tool-testkit": "workspace:*"
},
"dependencies": {
"@deepseek-ai/dsh-util-action-block": "workspace:*",
"yaml": "^2.9.0",
"resolve.exports": "^2.0.3",
"zod": "4.4.3",
@@ -1,23 +0,0 @@
/** Model-written proposal blocks: fenced `dsh-action` JSON objects in an assistant answer. */
/** Fence language that marks a proposal block; the chat hides fences with this language. */
export const ACTION_BLOCK_LANGUAGE = 'dsh-action'
const FENCE = /```dsh-action[^\n]*\n([\s\S]*?)\n```/gu
/** Parse every well-formed JSON object inside `dsh-action` fences, in order; malformed fences are skipped.
* @param text - assistant answer text.
* @returns parsed objects; callers validate their fields.
*/
export function actionBlocksOf(text: string): unknown[] {
const objects: unknown[] = []
for (const match of text.matchAll(FENCE)) {
try {
const value: unknown = JSON.parse(String(match[1]).trim())
if (typeof value === 'object' && value !== null && !Array.isArray(value)) objects.push(value)
} catch {
// Malformed JSON inside a fence creates no proposal; the answer stays as written.
}
}
return objects
}
@@ -21,7 +21,6 @@ import { creationProjection, customLocationSchema, slotSchema, type CreationStat
import { validateGeneratedBundle } from './generated-bundle.ts'
import type { CreationActionResult, CreationDecision, CreationRecommendation, CreationSlot } from './types.ts'
export type * from './types.ts'
export { cardBlocksOf, type CardBlock } from './projection.ts'
export const name = 'creation-recommendations'
@@ -4,7 +4,7 @@ import { ToolCallId } from '@deepseek-ai/dsh-llm'
import { pluginRecordOf, type SessionEvent } from '@deepseek-ai/dsh-session'
import type { ProjectionDefinition } from '@deepseek-ai/dsh-session-projection'
import type { CreationRecommendation } from './types.ts'
import { actionBlocksOf } from './action-block.ts'
import { actionBlocksOf, actionOf } from '@deepseek-ai/dsh-util-action-block'
import type {} from '@deepseek-ai/dsh-tool-executor-ptc/types'
/** Model and wire values use the same three supported locations. */
@@ -33,35 +33,33 @@ const stateSchema = z.object({
queued: z.number(),
})
/** The current turn's reservation also covers official-plugin recommendations. */
/** The current turn's reservation also covers cards of other experiments. */
export type CreationState = z.infer<typeof stateSchema>
const blockSchema = proposalSchema.omit({ callId: true }).extend({
action: z.literal('create_personalized_page'), title: z.string().trim().min(1), brief: z.string().trim().min(1),
}).strict()
const officialPluginBlockSchema = z.object({
action: z.literal('enable_official_plugin'), name: z.string().trim().min(1), reason: z.string().trim().min(1),
}).strict()
const cardBlockSchema = z.discriminatedUnion('action', [blockSchema, officialPluginBlockSchema])
const ACTION = 'create_personalized_page'
/** A valid `dsh-action` block of either official card experiment. */
export type CardBlock = z.infer<typeof cardBlockSchema>
/** Read the valid card blocks of an assistant answer, in order. The last one is the answer's card,
* so a page block followed by an official-plugin block offers the plugin, and the reverse offers the page.
/** Blocks of an assistant answer: its last valid page block and whether another action's block is present.
* @param event - logged session event.
* @returns the answer's card id, its valid blocks and its card, when the event is an answer with at least one.
* @returns the answer's card id, its page block and the foreign flag; undefined for other events and answers without blocks.
*/
export function cardBlocksOf(event: SessionEvent):
{ readonly callId: ToolCallId; readonly blocks: readonly CardBlock[]; readonly card: CardBlock } | undefined {
function answerBlocksOf(event: SessionEvent):
{ readonly callId: ToolCallId; readonly page: z.infer<typeof blockSchema> | undefined; readonly foreign: boolean } | undefined {
if (event.type !== 'assistant/message') return undefined
const text = event.data.message.content.flatMap(block => block.type === 'text' ? [block.text] : []).join('\n')
const blocks = actionBlocksOf(text).flatMap((value) => {
const parsed = cardBlockSchema.safeParse(value)
const blocks = actionBlocksOf(text)
if (blocks.length === 0) return undefined
const pages = blocks.flatMap((value) => {
const parsed = blockSchema.safeParse(value)
return parsed.success ? [parsed.data] : []
})
const card = blocks.at(-1)
return card === undefined ? undefined : { callId: ToolCallId(`block:${event.data.message.id}`), blocks, card }
const foreign = blocks.some((value) => {
const action = actionOf(value)
return action !== undefined && action !== ACTION
})
return { callId: ToolCallId(`block:${event.data.message.id}`), page: pages.at(-1), foreign }
}
/** Read a proposal from a logged event: the last valid page block of an assistant answer,
@@ -71,10 +69,9 @@ export function cardBlocksOf(event: SessionEvent):
*/
export function creationOf(event: SessionEvent): CreationRecommendation | undefined {
if (event.type === 'assistant/message') {
const answer = cardBlocksOf(event)
const last = answer?.blocks.filter(block => block.action === 'create_personalized_page').at(-1)
if (answer === undefined || last === undefined) return undefined
const { action: _action, ...fields } = last
const answer = answerBlocksOf(event)
if (answer?.page === undefined) return undefined
const { action: _action, ...fields } = answer.page
return { callId: answer.callId, ...fields }
}
const result = settlement(event)
@@ -108,7 +105,7 @@ export type CreationProjectionDefinition = Omit<ProjectionDefinition<'creationRe
*/
export function creationProjection(limits: CreationLimits): CreationProjectionDefinition {
return {
key: 'creationRecommendations', stateVersion: 9, stateSchema,
key: 'creationRecommendations', stateVersion: 10, stateSchema,
init: (): CreationState => ({ proposals: {}, view: { decisions: {} }, turn: 0, firstCall: null, pendingStatus: {}, queued: 0 }),
apply: (state, event) => {
if (event.type === 'turn/start') return { ...state, turn: event.data.turn, firstCall: null }
@@ -127,20 +124,26 @@ export function creationProjection(limits: CreationLimits): CreationProjectionDe
pendingStatus: { ...state.pendingStatus, [decision.callId]: event.seq } }
}
const proposal = creationOf(event)
const answer = cardBlocksOf(event)
const answer = answerBlocksOf(event)
if (proposal === undefined) {
const foreign = z.union([
const legacy = z.union([
z.object({ pluginRecommendation: z.object({ callId: z.string().min(1) }) }).transform(value => value.pluginRecommendation.callId),
z.object({ actionCard: z.object({ action: z.string(), callId: z.string().min(1) }) }).transform(value => value.actionCard.callId),
]).safeParse(settlement(event)?.meta)
const firstCall = state.firstCall ?? answer?.callId ?? (foreign.success ? foreign.data : null)
return firstCall === state.firstCall ? state : { ...state, firstCall }
const other = answer?.foreign === true ? answer.callId : legacy.success ? legacy.data : null
if (other === null) return state
// Pages yield: another action's card later in the turn retracts this turn's undecided page card.
const held = state.firstCall !== null ? state.proposals[state.firstCall] : undefined
if (state.firstCall !== null && held !== undefined && state.view.decisions[state.firstCall] === undefined) {
const decisions = { ...state.view.decisions, [held.callId]: { callId: held.callId, phase: 'suppressed' as const, slot: held.slot } }
return { ...state, view: { decisions } }
}
return state.firstCall === null ? { ...state, firstCall: other } : state
}
const firstCall = state.firstCall ?? proposal.callId
const decisions = { ...state.view.decisions }
// Another experiment's card holds this answer or turn: the page block is logged but never offered.
const foreignCard = answer?.card.action === 'enable_official_plugin'
|| firstCall !== proposal.callId && state.proposals[firstCall] === undefined
// Pages yield: another action's block in this answer, or another card earlier in this turn, keeps the page block from being offered.
const foreignCard = answer?.foreign === true || firstCall !== proposal.callId && state.proposals[firstCall] === undefined
// One card per turn: a block written after another card in this turn creates nothing.
if (!foreignCard && firstCall !== proposal.callId) return state
// Queued user messages mean the user has moved on; the proposal is logged but never offered.
@@ -30,8 +30,8 @@ export interface CreationDecision {
}
/** Replacement and suppression are derived from the log, never written as plugin decisions: a later proposal
* replaces an unstarted card; a proposal written while user messages are queued, or after an official-plugin card
* in the same answer or turn, is never offered. */
* replaces an unstarted card; a proposal written while user messages are queued, or in an answer or turn that also
* carries another action's block, is never offered. */
export type CreationCardState = CreationDecision | (Omit<CreationDecision, 'phase'> & { readonly phase: 'replaced' | 'suppressed' })
/** Open-session receipts, excluded from session-list summaries. */
@@ -1,7 +1,7 @@
import { expect, it } from 'vitest'
import { createToolResultMessage, ToolCallId } from '@deepseek-ai/dsh-llm'
import { SessionSeq, type SessionEvent } from '@deepseek-ai/dsh-session'
import { cardBlocksOf, creationOf, creationProjection as projectionFor } from '../src/projection.ts'
import { creationOf, creationProjection as projectionFor } from '../src/projection.ts'
import { MessageId, type AssistantMessage } from '@deepseek-ai/dsh-llm'
const creationProjection = projectionFor({ queuedMessageLimit: 2 })
@@ -42,7 +42,7 @@ it('folds native and PTC metadata, ignores failed or malformed proposals, and re
}
const foreign = { ...ptc, data: { ...ptc.data, meta: { pluginRecommendation: { callId: 'official' } } } }
expect(creationProjection.apply(empty, foreign).firstCall).toBe('official')
expect(creationProjection.apply(state, foreign)).toBe(state)
expect(creationProjection.apply(state, foreign).view.decisions[callId]).toEqual({ callId, phase: 'suppressed', slot: proposal.slot })
expect(creationProjection.apply(state, { type: 'turn/start', seq: SessionSeq(3), time: 0, data: { turn: 2 } }).firstCall).toBeNull()
})
@@ -86,25 +86,30 @@ it('reads the last valid dsh-action block of an answer, keeps one card per turn,
expect(queued.view.decisions[ToolCallId('block:m5')]).toEqual({ callId: ToolCallId('block:m5'), phase: 'suppressed', slot: 'page' })
})
it('keeps one card per turn across both card actions and logs a page block that is not the card as never offered', () => {
it('yields to another action in the same answer or turn and logs the page block as never offered', () => {
const page = { action: 'create_personalized_page', title: 'Counter', brief: 'Keep count visible', slot: 'page' as const }
const plugin = { action: 'enable_official_plugin', name: 'official-search', reason: 'Search past work.' }
const fence = (fields: object) => '```dsh-action\n' + JSON.stringify(fields) + '\n```'
const empty = creationProjection.init()
// The last valid block of an answer is its card, across both actions.
const pluginLast = creationProjection.apply(empty, answer('m1', fence(page) + '\n' + fence(plugin)))
expect(pluginLast.view.decisions[ToolCallId('block:m1')]).toEqual({ callId: ToolCallId('block:m1'), phase: 'suppressed', slot: 'page' })
const pageLast = creationProjection.apply(empty, answer('m1', fence(plugin) + '\n' + fence(page)))
expect(pageLast.view.decisions).toEqual({})
expect(pageLast.proposals[ToolCallId('block:m1')]).toBeDefined()
// An official-plugin answer earlier in the turn holds it.
// Another action's block anywhere in the answer wins, whatever the order.
for (const text of [fence(page) + '\n' + fence(plugin), fence(plugin) + '\n' + fence(page)]) {
const state = creationProjection.apply(empty, answer('m1', text))
expect(state.view.decisions[ToolCallId('block:m1')]).toEqual({ callId: ToolCallId('block:m1'), phase: 'suppressed', slot: 'page' })
expect(state.proposals[ToolCallId('block:m1')]).toBeDefined()
}
// Another action's answer earlier in the turn holds it; a block that names no action does not.
const official = creationProjection.apply(empty, answer('m1', fence(plugin)))
expect(official.firstCall).toBe('block:m1')
expect(creationProjection.apply(official, answer('m1', fence(plugin)))).toBe(official)
expect(creationProjection.apply(empty, answer('m1', fence({ title: 'no action' })))).toBe(empty)
expect(creationProjection.apply(official, answer('m2', fence(page), 8)).view.decisions[ToolCallId('block:m2')])
.toEqual({ callId: ToolCallId('block:m2'), phase: 'suppressed', slot: 'page' })
expect(cardBlocksOf(answer('m3', fence({ ...plugin, name: ' ' }) + '\n' + fence({ ...plugin, extra: 1 })))).toBeUndefined()
expect(cardBlocksOf(answer('m3', fence({ ...plugin, name: ' trimmed ' })))?.card).toEqual({ ...plugin, name: 'trimmed' })
// Another action's block later in the turn retracts this turn's undecided page card.
const offered = creationProjection.apply(empty, answer('m1', fence(page)))
expect(offered.view.decisions).toEqual({})
const retracted = creationProjection.apply(offered, answer('m2', fence(plugin), 8))
expect(retracted.view.decisions[ToolCallId('block:m1')]).toEqual({ callId: ToolCallId('block:m1'), phase: 'suppressed', slot: 'page' })
expect(creationProjection.apply(retracted, answer('m3', fence(plugin), 9))).toBe(retracted)
})
it('reads only text parts of an answer and ignores splices outside the next-turn queue', () => {
@@ -48,14 +48,14 @@
},
{
"path": "../../util/values"
}
},
{ "path": "../../util/action-block" }
],
"files": [
"src/index.ts",
"src/types.ts",
"src/projection.ts",
"src/generated-bundle.ts",
"src/skill.ts",
"src/action-block.ts"
"src/skill.ts"
]
}
@@ -15,17 +15,17 @@
en: d152484eb41ac6b4
zh: 59094206ec49ad20
/deepseek-ai-dsh-experimental-plugin-recommendations/use-this-package:
en: 4df678f652205cbe
zh: 9d917aabc1c2d87f
en: c256d00c58fc83e9
zh: da10e326cce84f55
/deepseek-ai-dsh-experimental-plugin-recommendations/use-this-package/outcomes-and-recovery:
en: 106e39516c2121ec
zh: 8bfa9496ac9e6bf2
/deepseek-ai-dsh-experimental-plugin-recommendations/understand-the-implementation:
en: f599b66e64ce557e
zh: 928b41ab1f8e2368
en: 615000aea117be73
zh: 58b4ebc63ad05a82
/deepseek-ai-dsh-experimental-plugin-recommendations/further-exploration:
en: 5db668685185b67b
zh: c0270c295a3b1e43
en: 3f8ee874794fade7
zh: 3e1e9afc8fb26fb7
/deepseek-ai-dsh-experimental-plugin-recommendations/model-experience:
en: 215e7ba838619b7b
zh: a311da3843709f90
@@ -42,8 +42,8 @@
en: 086a06d4732a88cc
zh: 610597117579cfa4
/deepseek-ai-dsh-experimental-plugin-recommendations/known-limitations-and-deferred-work:
en: 3ea277cf88ec5447
zh: 6cb50ea7ab168f52
en: 0f2c45e2eef7a454
zh: 8f7014a055c7548e
/deepseek-ai-dsh-experimental-plugin-recommendations/known-limitations-and-deferred-work/dev-note:
en: e67f633dc6fe7773
zh: e88aeb617ffb74ea
@@ -33,7 +33,7 @@ Open Plugins → More → Experimental plugins and enable Plugin recommendations
queuedMessageLimit: 2
```
The experiment works with or without Personalized pages. Both use the same answer-block format and share one card per turn: the last valid card block of an answer is its card, and the first answer with a card holds the turn. For several independent needs, guidance follows an explicit user priority and otherwise prefers an existing plugin over a new page; other needs stay in the answer.
The experiment works with or without Personalized pages. Both read the same answer-block format through `@deepseek-ai/dsh-util-action-block`, and a turn shows at most one card because Personalized pages yields to any other action's block while this experiment never yields; neither package depends on the other. For several independent needs, guidance follows an explicit user priority and otherwise prefers an existing plugin over a new page; other needs stay in the answer.
### Outcomes and recovery
@@ -49,7 +49,7 @@ Enable requires permission to modify the profile. The Host rechecks catalog elig
The Host entry is a function plugin. Its `apply` assembles the internal `pluginRecommendations` Remote service with `ctx.plugin`. That service owns feature state, effect lifetimes and typed button calls; it is not a separately configurable or replaceable YAML entry, and adds no service to `dsh-base`. Disabling the feature disposes its internal service, skill provider and listeners and drains its owned asynchronous work. This follows the existing `command-feedback` composition of `sessionFeedback`.
The plugin adds no model tool and no system-prompt section. It registers the `official-plugins` skill; loading it appends the current eligible catalog from `pluginManager`. A proposal is one fenced `dsh-action` JSON object with `action: "enable_official_plugin"`, `name` and `reason` at the end of the final answer. The Session projection reads the answer's last valid card block through the block reader of Personalized pages, identifies the card by the message id, and applies the same turn reservation, queue suppression and replacement rules. The card asks the Host for the plugin's current title, description and selection, so names outside the eligible catalog show no actions. Only shipped, optional, official plugins that are available, writable and error-free are eligible.
The plugin adds no model tool and no system-prompt section. It registers the `official-plugins` skill; loading it appends the current eligible catalog from `pluginManager`. A proposal is one fenced `dsh-action` JSON object with `action: "enable_official_plugin"`, `name` and `reason` at the end of the final answer. The Session projection reads the answer's last valid official-plugin block through `@deepseek-ai/dsh-util-action-block`, identifies the card by the message id, and applies the same per-turn reservation, queue suppression and replacement rules as Personalized pages, without depending on that package. The card asks the Host for the plugin's current title, description and selection, so names outside the eligible catalog show no actions. Only shipped, optional, official plugins that are available, writable and error-free are eligible.
Concurrent actions on one card share an operation, and disposal stops local follow-up without waiting on a profile mutation queued behind its own HMR unload. Plugin Manager owns an accepted mutation; an unconfirmed card rechecks the profile when clicked again. Profile files own installation state; experimental Session records own only the historical decision. Unreadable receipts block actions and show a recovery message without stalling other Session projections. Each decision is queued as a status notice for the next model request through the existing request-preparation hook; delivery is acknowledged only after the notice is logged.
@@ -66,7 +66,7 @@ Concurrent actions on one card share an operation, and disposal stops local foll
- [Plugin manager](../../boot/plugin-manager/README.md) — profile changes.
- [Session projection](../../session/session-projection/README.md) — durable derived state.
- [Recommendation cards](../client-ui-plugin-recommendations/README.md) — completed-answer placement.
- [Personalized pages](../creation-recommendations/README.md) — the other card action and the shared block reader.
- [Personalized pages](../creation-recommendations/README.md) — the other card action; it yields to this experiment's block.
<a id="model-experience"></a>
## Model Experience
@@ -93,7 +93,7 @@ The manual real-model input set is in `evaluation/intent-cases.json`; unit tests
- Only shipped, optional, official plugins are eligible; missing packages and third-party registries are excluded.
- Recommendations require a live root conversation; there is no unsolicited opportunity discovery.
- The block reader and turn rules come from Personalized pages, and the status notice and card tail are copies of its code, until a shared action-card package owns them.
- The block parser is shared through `@deepseek-ai/dsh-util-action-block`; the turn rules, status notice and card tail are copies of the Personalized pages code until a shared action-card package owns them.
- A historical receipt does not continuously track changes later made on the Plugins page.
<a id="dev-note"></a>
@@ -33,7 +33,7 @@ kind: "package-bundle"
queuedMessageLimit: 2
```
本实验可以单独使用,也可以和「个性化页面」同时开启。两者使用同一种回答块格式,每轮合计一张卡:一条回答以其中最后一个合法卡片块为准,本轮第一条带卡片的回答占用这一轮。多个独立需求时,指引优先遵循用户明确的优先级,否则现成插件优先于新建页面;其余需求在回答正文中说明。
本实验可以单独使用,也可以和「个性化页面」同时开启。两者通过 `@deepseek-ai/dsh-util-action-block` 读取同一种回答块格式;每轮最多一张卡,因为「个性化页面」会对任何别的动作块让位,而本实验从不让位;两个包互不依赖。多个独立需求时,指引优先遵循用户明确的优先级,否则现成插件优先于新建页面;其余需求在回答正文中说明。
### 结果与恢复
@@ -49,7 +49,7 @@ kind: "package-bundle"
Host 入口是函数插件。它的 `apply` 通过 `ctx.plugin` 组装内部的 `pluginRecommendations` Remote service。该 service 负责功能状态、effect 生命周期和类型化按钮调用;它不是可单独配置或替换的 YAML 条目,也不会向 `dsh-base` 添加 service。关闭功能会一并释放内部 service、skill provider 和监听器,并等待其拥有的异步工作结束。这沿用了现有 `command-feedback` 组装 `sessionFeedback` 的做法。
本插件不添加模型工具,也不添加 system prompt 段。它注册 `official-plugins` skill,加载时追加来自 `pluginManager` 的当前可推荐目录。提案是最终回答末尾的一个 `dsh-action` 围栏 JSON 对象,含 `action: "enable_official_plugin"`、`name` 和 `reason`。Session 投影通过「个性化页面」的块读取函数取回答中最后一个合法卡片块,以消息 id 标识卡片,并使用相同的每轮预留、排队抑制和替换规则。卡片向 Host 读取插件当前的标题、描述和启用状态,因此不在可推荐目录里的名字不会显示操作按钮。只有已随安装提供、可选、官方、可读取、可写且无错误的插件可以推荐。
本插件不添加模型工具,也不添加 system prompt 段。它注册 `official-plugins` skill,加载时追加来自 `pluginManager` 的当前可推荐目录。提案是最终回答末尾的一个 `dsh-action` 围栏 JSON 对象,含 `action: "enable_official_plugin"`、`name` 和 `reason`。Session 投影用共享的块解析器取回答中最后一个合法的官方插件块,以消息 id 标识卡片,并使用相同的每轮预留、排队抑制和替换规则。卡片向 Host 读取插件当前的标题、描述和启用状态,因此不在可推荐目录里的名字不会显示操作按钮。只有已随安装提供、可选、官方、可读取、可写且无错误的插件可以推荐。
同一张卡上的并发操作共用一次执行;卸载时停止本地后续处理,不等待排在自身 HMR 卸载之后的 profile 变更。已接纳的变更由 Plugin Manager 负责完成;结果未确认的卡片再次点击时会重新检查 profile。安装状态由 profile 文件负责,实验性 Session 记录只保存历史决定。无法读取的回执会阻止操作并显示恢复提示,但不会卡住其他 Session 投影。每个决定都会通过现有的请求准备钩子排入下一次模型请求的状态通知;通知写入日志后才确认送达。
@@ -66,7 +66,7 @@ Host 入口是函数插件。它的 `apply` 通过 `ctx.plugin` 组装内部的
- [插件管理器](../../boot/plugin-manager/README.zh.md) — 配置档案变更。
- [Session 投影](../../session/session-projection/README.zh.md) — 持久化派生状态。
- [推荐卡片](../client-ui-plugin-recommendations/README.zh.md) — 回答末尾的推荐入口。
- [个性化页面](../creation-recommendations/README.zh.md) — 另一种卡片动作和共用的块读取函数。
- [个性化页面](../creation-recommendations/README.zh.md) — 另一种卡片动作;它会对本实验的块让位。
<a id="model-experience"></a>
## 模型体验
@@ -93,7 +93,7 @@ Skill 内容和提案块追加到对话记录;公共 system prompt 和工具
- 只推荐已随安装提供、可选的官方插件;缺失的包和第三方注册源不在范围内。
- 推荐需要在线的根会话;不会主动寻找推荐机会。
- 块读取函数和每轮规则来自「个性化页面」,状态通知和卡片尾部组件是其代码的副本,之后由共享动作卡包统一负责。
- 块解析器通过 `@deepseek-ai/dsh-util-action-block` 共享;每轮规则、状态通知和卡片尾部组件仍是「个性化页面」代码的副本,之后由共享动作卡包统一负责。
- 历史回执不会持续跟踪之后在插件页做的修改。
<a id="dev-note"></a>
@@ -58,7 +58,7 @@
},
"dependencies": {
"zod": "4.4.3",
"@deepseek-ai/dsh-experimental-creation-recommendations": "workspace:*",
"@deepseek-ai/dsh-util-action-block": "workspace:*",
"@deepseek-ai/dsh-experimental-client-ui-plugin-recommendations": "workspace:*"
},
"files": [
@@ -1,9 +1,9 @@
/** Validate answer blocks and fold card decisions without scanning session history. */
import { z } from 'zod'
import { ToolCallId } from '@deepseek-ai/dsh-llm'
import { pluginRecordOf } from '@deepseek-ai/dsh-session'
import { pluginRecordOf, type SessionEvent } from '@deepseek-ai/dsh-session'
import type { ProjectionDefinition } from '@deepseek-ai/dsh-session-projection'
import { cardBlocksOf } from '@deepseek-ai/dsh-experimental-creation-recommendations'
import { actionBlocksOf } from '@deepseek-ai/dsh-util-action-block'
import type { PluginRecommendation, RecommendationOutcome } from './types.ts'
const recommendationSchema: z.ZodType<PluginRecommendation> = z.object({
@@ -11,6 +11,24 @@ const recommendationSchema: z.ZodType<PluginRecommendation> = z.object({
name: z.string().min(1), reason: z.string().min(1),
}).strict()
const blockSchema = z.object({
action: z.literal('enable_official_plugin'), name: z.string().trim().min(1), reason: z.string().trim().min(1),
}).strict()
/** Read a proposal from a logged answer: its last valid official-plugin block. Blocks of other actions are ignored.
* @param event - logged session event.
* @returns the proposal, when the event is an answer carrying a valid block.
*/
export function recommendationOf(event: SessionEvent): PluginRecommendation | undefined {
if (event.type !== 'assistant/message') return undefined
const text = event.data.message.content.flatMap(block => block.type === 'text' ? [block.text] : []).join('\n')
const last = actionBlocksOf(text).flatMap((value) => {
const parsed = blockSchema.safeParse(value)
return parsed.success ? [parsed.data] : []
}).at(-1)
return last === undefined ? undefined : { callId: ToolCallId(`block:${event.data.message.id}`), name: last.name, reason: last.reason }
}
const outcomeSchema: z.ZodType<RecommendationOutcome> = z.enum([
'dismissed', 'applied', 'already-enabled', 'restart-required', 'overridden', 'failed', 'cancelled',
])
@@ -51,7 +69,7 @@ export type RecommendationProjectionDefinition = Omit<Definition, 'init' | 'wire
*/
export function recommendationProjection(limits: RecommendationLimits): RecommendationProjectionDefinition {
return {
key: 'pluginRecommendations', stateVersion: 4, stateSchema,
key: 'pluginRecommendations', stateVersion: 5, stateSchema,
init: (): RecommendationState => ({ proposals: {}, view: { decisions: {} }, turn: 0, firstCall: null, pendingStatus: {}, queued: 0 }),
apply: (state, event) => {
// Same as personalized pages until the shared action-card layer owns card folds.
@@ -74,20 +92,15 @@ export function recommendationProjection(limits: RecommendationLimits): Recommen
return { ...state, view: { ...state.view, decisions: { ...state.view.decisions, [callId]: outcome } },
pendingStatus: { ...state.pendingStatus, [callId]: event.seq } }
}
const answer = cardBlocksOf(event)
if (answer === undefined) return state
const firstCall = state.firstCall ?? answer.callId
const last = answer.blocks.filter(block => block.action === 'enable_official_plugin').at(-1)
if (last === undefined) return firstCall === state.firstCall ? state : { ...state, firstCall }
const proposal: PluginRecommendation = { callId: answer.callId, name: last.name, reason: last.reason }
const proposal = recommendationOf(event)
if (proposal === undefined) return state
const firstCall = state.firstCall ?? proposal.callId
// One card per turn: this experiment's later block in the same turn creates nothing.
// Other experiments' blocks never displace a plugin card; Personalized pages yields instead.
if (firstCall !== proposal.callId) return state
const decisions = { ...state.view.decisions }
// Another experiment's card holds this answer or turn: the block is logged but never offered.
const foreignCard = answer.card.action !== 'enable_official_plugin'
|| firstCall !== proposal.callId && state.proposals[firstCall] === undefined
// One card per turn: a block written after another card in this turn creates nothing.
if (!foreignCard && firstCall !== proposal.callId) return state
// Queued user messages mean the user has moved on; the proposal is logged but never offered.
if (foreignCard || state.queued >= limits.queuedMessageLimit) {
if (state.queued >= limits.queuedMessageLimit) {
decisions[proposal.callId] = 'suppressed'
return { ...state, firstCall, proposals: { ...state.proposals, [proposal.callId]: proposal }, view: { ...state.view, decisions } }
}
@@ -1,7 +1,7 @@
import { expect, it } from 'vitest'
import { createUserMessage, ToolCallId } from '@deepseek-ai/dsh-llm'
import { SessionSeq, type SessionEvent } from '@deepseek-ai/dsh-session'
import { recommendationProjection } from '../src/projection.ts'
import { recommendationOf, recommendationProjection } from '../src/projection.ts'
import { answer, block } from './fixture.ts'
const projection = recommendationProjection({ queuedMessageLimit: 2 })
@@ -27,29 +27,32 @@ it('reads the last valid official-plugin block of an answer and ignores malforme
expect(projection.wire.view(apply(empty, event('assistant/message', answer('m1', [block]))))).toEqual({ decisions: {} })
})
it('keeps one card per turn across both card actions and logs the losing block as never offered', () => {
it('never yields to another action and keeps one card per turn of its own', () => {
const empty = projection.init()
// A page block written after the plugin block makes the page the answer's card.
const pageLast = apply(empty, event('assistant/message', answer('m1', [block, page])))
expect(pageLast.view.decisions[id('m1')]).toBe('suppressed')
expect(pageLast.firstCall).toBe(id('m1'))
const pluginLast = apply(empty, event('assistant/message', answer('m1', [page, block])))
expect(pluginLast.view.decisions).toEqual({})
expect(pluginLast.proposals[id('m1')]).toBeDefined()
// A page card earlier in the turn holds the turn, also when this experiment's block comes later.
// Page blocks in the same answer, before or after, do not change this experiment's card.
for (const blocks of [[block, page], [page, block]]) {
const state = apply(empty, event('assistant/message', answer('m1', blocks)))
expect(state.view.decisions).toEqual({})
expect(state.proposals[id('m1')]).toBeDefined()
}
// A page answer earlier in the turn neither holds the turn nor hides a later plugin block.
const pageFirst = apply(empty, event('assistant/message', answer('m1', [page])))
expect(pageFirst.firstCall).toBe(id('m1'))
expect(apply(pageFirst, event('assistant/message', answer('m1', [page])))).toBe(pageFirst)
expect(apply(pageFirst, event('assistant/message', answer('m2', [block]))).view.decisions[id('m2')]).toBe('suppressed')
expect(pageFirst).toBe(empty)
expect(apply(pageFirst, event('assistant/message', answer('m2', [block]))).proposals[id('m2')]).toBeDefined()
// This experiment's own later block in the same turn creates nothing.
const own = apply(empty, event('assistant/message', answer('m1', [block])))
const later = apply(own, event('assistant/message', answer('m2', [{ ...block, name: 'later' }])))
expect(later).toBe(own)
expect(apply(own, event('assistant/message', answer('m3', [page])))).toBe(own)
expect(apply(own, event('assistant/message', answer('m1', [block])))).toEqual(own)
expect(apply(own, event('turn/start', { turn: 2 })).firstCall).toBeNull()
})
it('reads only the text parts of an answer', () => {
const data = answer('m1', [block])
const message = { ...data.message, content: [{ type: 'tool-call' as const, id: ToolCallId('call'), name: 'skill', arguments: '{}' }, ...data.message.content] }
expect(recommendationOf(event('assistant/message', { ...data, message }))).toEqual({ callId: id('m1'), name: block.name, reason: block.reason })
})
it('lets a later proposal replace only undecided cards', () => {
const first = apply(projection.init(), event('assistant/message', answer('m1', [block])))
const second = apply(first, event('turn/start', { turn: 2 }), event('assistant/message', answer('m2', [block], 2)))
@@ -5,7 +5,7 @@ import { executeToolFixture } from '@deepseek-ai/dsh-tool-testkit'
import type { BundleInfo, ChangeResult } from '@deepseek-ai/dsh-plugin-manager/types'
import * as PluginRecommendations from '../src/index.ts'
import type { RecommendationDecision } from '../src/types.ts'
import { block, bundle, callId, fixture } from './fixture.ts'
import { bundle, callId, fixture } from './fixture.ts'
describe('official recommendations', () => {
it('offers the live catalog through a skill, adds no tools, and withdraws it on unload', async () => {
@@ -150,9 +150,11 @@ describe('official recommendations', () => {
it('settles cards that were never offered or were replaced without changing the profile', async () => {
const f = await fixture()
const page = { action: 'create_personalized_page', title: 'Counter', brief: 'Count tasks.', slot: 'page' }
const suppressed = f.propose('answer-1', [block, page])
const message = createUserMessage({ content: [{ type: 'text', text: 'Next question.' }], source: { kind: 'user' } })
f.agent.session.append('agent/inbox/spliced', { target: 'next-turn', start: 0, inserted: [message, message] })
const suppressed = f.propose('answer-1')
expect(await f.ctx.pluginRecommendations.act(f.agent, suppressed, 'enable')).toEqual({ status: 'settled', outcome: 'suppressed' })
f.agent.session.append('agent/inbox/spliced', { target: 'next-turn', start: 0, removedCount: 2, inserted: [] })
f.agent.session.append('turn/start', { turn: 2 })
const first = f.propose('answer-2')
f.agent.session.append('turn/start', { turn: 3 })
@@ -36,7 +36,7 @@
"path": "../../skill/skill"
},
{
"path": "../creation-recommendations"
"path": "../../util/action-block"
}
],
"files": [
+2 -2
View File
@@ -15,8 +15,8 @@
en: 1eb35b015a6c423f
zh: a8a3dceee2c9e943
/util-shared-utilities/packages:
en: 6f788656548ee4e0
zh: 227695dafaa9476d
en: aaf1ba8a1fefebc9
zh: 533926fdc788fc4b
/util-shared-utilities/related-documentation:
en: 432ae81110914571
zh: c5bf4efc007311f3
+1
View File
@@ -37,6 +37,7 @@ Each package provides one primitive; open a package page for how to use it.
| [`home-paths/`](home-paths/README.md) | Resolves the single Harness home and joins shared user-data paths |
| [`http-proxy/`](http-proxy/README.md) | Resolves one outbound proxy policy and installs it for `fetch`, SDK agents, and spawned children |
| [`launch-environment/`](launch-environment/README.md) | Frozen launch environment that remembers which layer supplied each value |
| [`action-block/`](action-block/README.md) | Parses the fenced `dsh-action` proposal blocks an answer ends with and names their actions |
| [`atomic-write/`](atomic-write/README.md) | Atomic file replacement and cross-process writer locking |
| [`lazy-require/`](lazy-require/README.md) | Loads CommonJS-compatible Host dependencies on first use with caller-relative resolution |
| [`native-command/`](native-command/README.md) | Runs host-native commands directly, never through a shell string |
+1
View File
@@ -37,6 +37,7 @@ kind: "package-group"
| [`home-paths/`](home-paths/README.zh.md) | 解析统一的 Harness 主目录并拼接共享的用户数据路径 |
| [`http-proxy/`](http-proxy/README.zh.md) | 解析出唯一的出站代理策略,并为 `fetch`、SDK agent(智能体)与 spawn 的子进程安装它 |
| [`launch-environment/`](launch-environment/README.zh.md) | 冻结的启动环境,记住每个值来自哪一层 |
| [`action-block/`](action-block/README.zh.md) | 解析回答末尾的 `dsh-action` 围栏提案块并辨认其动作 |
| [`atomic-write/`](atomic-write/README.zh.md) | 原子文件替换与跨进程写锁 |
| [`lazy-require/`](lazy-require/README.zh.md) | 在首次使用时按调用方位置加载兼容 CommonJS 的 Host 依赖 |
| [`native-command/`](native-command/README.zh.md) | 直接运行宿主原生命令,绝不拼 shell 字符串 |
@@ -0,0 +1,43 @@
# Bilingual-pair consistency record for README.md (docs/i18n/README.md): per heading
# section, a hash of its English and Chinese blocks outside code blocks and generated regions.
# After editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write packages/util/action-block/README.md
/:
en: c3cffc9fd1ddc0de
zh: 5abbfc238fcf6e4d
/deepseek-ai-dsh-util-action-block:
en: cf7b74342b6e13e6
zh: 88f591b07862f6fb
/deepseek-ai-dsh-util-action-block/summary:
en: eee5d7c4e08a7967
zh: 46cfdb996f87ae1e
/deepseek-ai-dsh-util-action-block/table-of-contents:
en: 80b31a934611cb4a
zh: 6195f049e8537637
/deepseek-ai-dsh-util-action-block/use-this-package:
en: bbff250eba0f1b89
zh: 0a618f05cc88ee2b
/deepseek-ai-dsh-util-action-block/use-this-package/read-the-blocks-of-an-answer:
en: e14b63273f46d2c6
zh: 185306d61e4ff55b
/deepseek-ai-dsh-util-action-block/use-this-package/tell-actions-apart:
en: 9273b5e38fc76f93
zh: 0541d64b400b4bcf
/deepseek-ai-dsh-util-action-block/use-this-package/hide-the-fences:
en: e04a95c30c884bf3
zh: ea029a3e72568b51
/deepseek-ai-dsh-util-action-block/understand-the-implementation:
en: 880a2381c4c13897
zh: 3481d98b8aecf182
/deepseek-ai-dsh-util-action-block/understand-the-implementation/source-map:
en: e07a11631358c555
zh: b2f06b27d41060ff
/deepseek-ai-dsh-util-action-block/further-exploration:
en: 4fa14cf65a65aa2e
zh: 2bd28a13141ef444
/deepseek-ai-dsh-util-action-block/known-limitations-and-deferred-work:
en: cc49513ebcd6cac3
zh: bdce7b0f1650f618
/deepseek-ai-dsh-util-action-block/known-limitations-and-deferred-work/dev-note:
en: e67f633dc6fe7773
zh: 8ec3e73528ddb1a6
+93
View File
@@ -0,0 +1,93 @@
---
description: "Parser for the fenced dsh-action JSON blocks an assistant answer ends with to propose a card, shared by the card experiments and the chat renderer."
kind: "package-library"
---
# @deepseek-ai/dsh-util-action-block
English | [中文](README.zh.md)
## Summary
Callers can read the `dsh-action` fenced blocks of an assistant answer as parsed JSON objects and tell which action each block names. The package owns only the fence grammar; every card experiment validates its own block fields and decides when a block becomes a card.
## Table of Contents
- [Use this package](#use-this-package)
- [Understand the implementation](#understand-the-implementation)
- [Further Exploration](#further-exploration)
- [Known Limitations and Deferred Work](#known-limitations-and-deferred-work)
- [Dev Note](#dev-note)
-----
<a id="use-this-package"></a>
## Use this package
### Read the blocks of an answer
`actionBlocksOf(text)` returns every well-formed JSON object inside a ```` ```dsh-action ```` fence, in answer order. Fences with malformed JSON, arrays or scalars produce nothing; other fence languages are ignored. Validate the fields of each object with the schema of the action that owns it.
```ts
import { actionBlocksOf, actionOf } from '@deepseek-ai/dsh-util-action-block'
declare const answerText: string
const blocks = actionBlocksOf(answerText)
const mine = blocks.filter(block => actionOf(block) === 'create_personalized_page')
const foreign = blocks.some(block => actionOf(block) !== undefined && actionOf(block) !== 'create_personalized_page')
```
### Tell actions apart
`actionOf(block)` returns the non-blank `action` string of a parsed block, or `undefined`. A projection uses it to find its own blocks and to notice that an answer also carries another experiment's block, without knowing that experiment's schema.
### Hide the fences
`ACTION_BLOCK_LANGUAGE` is the fence language. Chat renderers hide fences with this language from the rendered answer; transcript views keep the original text.
-----
<a id="understand-the-implementation"></a>
## Understand the implementation
<details>
<summary>Implementation internals — click to expand</summary>
One global regular expression matches each fence whose info string starts with `dsh-action`; the body between the fence lines is parsed with `JSON.parse` after trimming. The parser keeps no state and has no dependency, so a Host projection and a browser bundle read the same answer identically.
### Source map
| File | Role |
|---|---|
| [`src/index.ts`](src/index.ts) | Fence language, block parsing and the action discriminator |
</details>
-----
<a id="further-exploration"></a>
## Further Exploration
- [Utility package map](../README.md) — adjacent stateless helpers.
- [Personalized pages](../../experimental/creation-recommendations/README.md) — proposes new pages through these blocks.
- [Plugin recommendations](../../experimental/plugin-recommendations/README.md) — recommends official plugins through these blocks.
-----
## Known Limitations and Deferred Work
<a id="known-limitations-and-deferred-work"></a>
- **Fences must be closed and start at a line beginning** — a block the model leaves unterminated or indents is ordinary text; the answer is logged as written and no card appears.
- **No shared card rules yet** — one card per turn, replacement and status notices are still folded by each experiment; a shared action-card layer is deferred until both experiments have landed.
<a id="dev-note"></a>
### Dev Note
<details>
<summary>Working context for maintainers — click to expand</summary>
None.
</details>
+93
View File
@@ -0,0 +1,93 @@
---
description: "解析助手回答末尾用于提案卡片的 dsh-action 围栏 JSON 块,供卡片实验和聊天渲染共用。"
kind: "package-library"
---
# @deepseek-ai/dsh-util-action-block
[English](README.md) | 中文
## 概述
调用方可以把助手回答里的 `dsh-action` 围栏块读成已解析的 JSON 对象,并辨认每个块声明的动作。本包只拥有围栏语法;每个卡片实验各自校验自己的块字段,并决定一个块何时成为卡片。
## 目录
- [使用本包](#use-this-package)
- [理解实现](#understand-the-implementation)
- [延伸阅读](#further-exploration)
- [已知限制与待办](#known-limitations-and-deferred-work)
- [开发备注](#dev-note)
-----
<a id="use-this-package"></a>
## 使用本包
### 读取回答里的块
`actionBlocksOf(text)` 按回答顺序返回每个 ```` ```dsh-action ```` 围栏内格式正确的 JSON 对象。JSON 错误、数组或标量的围栏不产生任何块;其他围栏语言被忽略。每个对象的字段由拥有该动作的 schema 校验。
```ts
import { actionBlocksOf, actionOf } from '@deepseek-ai/dsh-util-action-block'
declare const answerText: string
const blocks = actionBlocksOf(answerText)
const mine = blocks.filter(block => actionOf(block) === 'create_personalized_page')
const foreign = blocks.some(block => actionOf(block) !== undefined && actionOf(block) !== 'create_personalized_page')
```
### 区分动作
`actionOf(block)` 返回已解析块的非空 `action` 字符串,否则返回 `undefined`。投影用它找出自己的块,并察觉同一回答里还带着另一个实验的块,而不必知道那个实验的 schema。
### 隐藏围栏
`ACTION_BLOCK_LANGUAGE` 是围栏语言。聊天渲染把这种语言的围栏从渲染后的回答里隐藏;轨迹视图保留原文。
-----
<a id="understand-the-implementation"></a>
## 理解实现
<details>
<summary>实现内部 — 点击展开</summary>
一个全局正则匹配每个信息串以 `dsh-action` 开头的围栏;围栏行之间的正文去掉首尾空白后交给 `JSON.parse`。解析器没有状态也没有依赖,因此 Host 投影和浏览器包对同一回答的读法完全一致。
### 源码地图
| 文件 | 职责 |
|---|---|
| [`src/index.ts`](src/index.ts) | 围栏语言、块解析和动作判别 |
</details>
-----
<a id="further-exploration"></a>
## 延伸阅读
- [工具包地图](../README.zh.md) — 相邻的无状态辅助包。
- [个性化页面](../../experimental/creation-recommendations/README.zh.md) — 通过这些块提案新页面。
- [插件推荐](../../experimental/plugin-recommendations/README.zh.md) — 通过这些块推荐官方插件。
-----
## 已知限制与待办
<a id="known-limitations-and-deferred-work"></a>
- **围栏必须闭合且从行首开始** — 模型没写结束符或加了缩进的块是普通文本;回答按原样记录,不出卡片。
- **还没有共享的卡片规则** — 一轮一张、替换和状态通知仍由各实验自己折叠;共享的动作卡层推迟到两个实验都合入之后。
<a id="dev-note"></a>
### 开发备注
<details>
<summary>维护者工作上下文 — 点击展开</summary>
无。
</details>
+35
View File
@@ -0,0 +1,35 @@
{
"name": "@deepseek-ai/dsh-util-action-block",
"description": "Fenced dsh-action proposal blocks an assistant answer ends with, parsed for the card experiments of the DeepSeek Harness",
"version": "0.2.1-rc.2",
"publishConfig": {
"access": "public"
},
"repository": {
"type": "git",
"url": "git+https://github.com/deepseek-ai/deepseek-harness.git",
"directory": "packages/util/action-block"
},
"type": "module",
"main": "lib/index.js",
"types": "lib/types/index.d.ts",
"exports": {
".": {
"types": "./lib/types/index.d.ts",
"default": "./lib/index.js"
},
"./src/*": "./src/*",
"./package.json": "./package.json"
},
"files": [
"lib/index.js",
"lib/types/**/*.d.ts"
],
"license": "MIT",
"peerDependencies": {
"@deepseek-ai/cordis": "workspace:~"
},
"devDependencies": {
"@deepseek-ai/cordis": "workspace:~"
}
}
+37
View File
@@ -0,0 +1,37 @@
/** Fenced `dsh-action` blocks: JSON objects an assistant answer ends with to propose a card the user may click.
* @module @deepseek-ai/dsh-util-action-block */
/** Fence language that marks a proposal block; chat renderers hide fences with this language. */
export const ACTION_BLOCK_LANGUAGE = 'dsh-action'
const FENCE = /```dsh-action[^\n]*\n([\s\S]*?)\n```/gu
/**
* Parse every well-formed JSON object inside `dsh-action` fences, in order. Malformed JSON and
* non-object values create no block; callers validate the fields of each object.
* @param text - assistant answer text.
* @returns parsed objects, in answer order.
*/
export function actionBlocksOf(text: string): unknown[] {
const objects: unknown[] = []
for (const match of text.matchAll(FENCE)) {
try {
const value: unknown = JSON.parse(String(match[1]).trim())
if (typeof value === 'object' && value !== null && !Array.isArray(value)) objects.push(value)
} catch {
// Malformed JSON inside a fence creates no block; the answer stays as written.
}
}
return objects
}
/**
* Read the action a parsed block names, so a card experiment can tell its own blocks from another's.
* @param block - a value returned by {@link actionBlocksOf}.
* @returns the non-blank `action` string, or undefined when the block names none.
*/
export function actionOf(block: unknown): string | undefined {
if (typeof block !== 'object' || block === null || !('action' in block)) return undefined
const { action } = block
return typeof action === 'string' && action.trim() !== '' ? action : undefined
}
@@ -1,11 +1,13 @@
import { expect, it } from 'vitest'
import { actionBlocksOf } from '../src/action-block.ts'
import { ACTION_BLOCK_LANGUAGE, actionBlocksOf, actionOf } from '../src/index.ts'
it('reads well-formed dsh-action objects in order and skips malformed or non-object fences', () => {
const text = [
'先回答问题。', '',
'```dsh-action', '{"action":"create_personalized_page","title":"A","brief":"B","slot":"page"}', '```', '',
`\`\`\`${ACTION_BLOCK_LANGUAGE}`, '{"action":"create_personalized_page","title":"A","brief":"B","slot":"page"}', '```', '',
'```dsh-action json', '[1, 2]', '```', '',
'```dsh-action', '"text"', '```', '',
'```dsh-action', 'null', '```', '',
'```dsh-action', '{not json', '```', '',
'```ts', '{"action":"ignored"}', '```', '',
'```dsh-action', '{"action":"other","title":"C"}', '```',
@@ -16,3 +18,8 @@ it('reads well-formed dsh-action objects in order and skips malformed or non-obj
])
expect(actionBlocksOf('没有围栏')).toEqual([])
})
it('names the action of a block only when it is a non-blank string', () => {
expect(actionOf({ action: 'enable_official_plugin', name: 'x' })).toBe('enable_official_plugin')
for (const block of [{ action: ' ' }, { action: 1 }, { title: 'A' }, null, 'text', 3]) expect(actionOf(block)).toBeUndefined()
})
+11
View File
@@ -0,0 +1,11 @@
{
"extends": "../../../tsconfig.base.json",
"compilerOptions": {
"rootDir": "src",
"outDir": "lib/types"
},
"include": [
"src"
],
"references": []
}
+17 -5
View File
@@ -8747,6 +8747,9 @@ importers:
'@deepseek-ai/dsh-typert-protocol':
specifier: workspace:*
version: link:../../typert/protocol
'@deepseek-ai/dsh-util-action-block':
specifier: workspace:*
version: link:../../util/action-block
react:
specifier: ^18.2.0
version: 18.3.1
@@ -8786,9 +8789,6 @@ importers:
'@deepseek-ai/dsh-client-ui-slots':
specifier: workspace:*
version: link:../../client/ui-slots
'@deepseek-ai/dsh-experimental-creation-recommendations':
specifier: workspace:*
version: link:../creation-recommendations
'@deepseek-ai/dsh-experimental-plugin-recommendations':
specifier: workspace:*
version: link:../plugin-recommendations
@@ -8804,6 +8804,9 @@ importers:
'@deepseek-ai/dsh-typert-protocol':
specifier: workspace:*
version: link:../../typert/protocol
'@deepseek-ai/dsh-util-action-block':
specifier: workspace:*
version: link:../../util/action-block
react:
specifier: ^18.2.0
version: 18.3.1
@@ -9011,6 +9014,9 @@ importers:
'@deepseek-ai/dsh-experimental-client-ui-creation-recommendations':
specifier: workspace:*
version: link:../client-ui-creation-recommendations
'@deepseek-ai/dsh-util-action-block':
specifier: workspace:*
version: link:../../util/action-block
resolve.exports:
specifier: ^2.0.3
version: 2.0.3
@@ -9630,9 +9636,9 @@ importers:
'@deepseek-ai/dsh-experimental-client-ui-plugin-recommendations':
specifier: workspace:*
version: link:../client-ui-plugin-recommendations
'@deepseek-ai/dsh-experimental-creation-recommendations':
'@deepseek-ai/dsh-util-action-block':
specifier: workspace:*
version: link:../creation-recommendations
version: link:../../util/action-block
zod:
specifier: 4.4.3
version: 4.4.3
@@ -16191,6 +16197,12 @@ importers:
specifier: workspace:*
version: link:../protocol
packages/util/action-block:
devDependencies:
'@deepseek-ai/cordis':
specifier: workspace:~
version: link:../../../vendor/cordis
packages/util/atomic-write:
devDependencies:
'@deepseek-ai/cordis':
+3 -3
View File
@@ -234,10 +234,10 @@ describe('client bundle purity gate', () => {
expect(() => resolveId('@deepseek-ai/dsh-spill-policy/notice/internal')).toThrow(/purity/)
})
it('admits only the pure proposal block parser of the creation experiment, not its Host plugin', () => {
expect(resolveId('@deepseek-ai/dsh-experimental-creation-recommendations/action-block')).toBeNull()
it('inlines the action-block parser utility while rejecting the card experiments themselves', () => {
expect(resolveId('@deepseek-ai/dsh-util-action-block')).toBeNull()
expect(() => resolveId('@deepseek-ai/dsh-experimental-creation-recommendations')).toThrow(/purity/)
expect(() => resolveId('@deepseek-ai/dsh-experimental-creation-recommendations/action-block/internal')).toThrow(/purity/)
expect(() => resolveId('@deepseek-ai/dsh-experimental-plugin-recommendations')).toThrow(/purity/)
})
it('inlines the browser-safe Timer entry while sharing Cordis through the module table', () => {
+1
View File
@@ -88,6 +88,7 @@ const PACKAGE_LIBRARIES: Readonly<Record<string, string>> = {
'packages/test-support/remote-mock': 'Browser-side test infrastructure; mounts nothing into a product composition.',
'packages/typert/generator': 'Build-time generator run outside any agent runtime.',
'packages/typert/protocol': 'Compiler-independent protocol declarations.',
'packages/util/action-block': 'Zero-dependency fenced-block parser; exports helpers without a plugin entry.',
'packages/util/atomic-write': 'Zero-dependency filesystem write utility.',
'packages/util/brand': 'Stateless nominal-string and canonical-key constructors.',
'packages/util/crypto': 'Zero-dependency identifier minting utility.',
@@ -35,6 +35,7 @@ const NO_MODEL_EXPERIENCE_SECTION: Readonly<Record<string, string>> = {
'packages/util/home-paths': 'The package only resolves harness-owned host paths; model-facing consumers own any rendered use.',
'packages/util/launch-environment': 'The package only resolves host environment values; model-facing consumers own any rendered use.',
'packages/util/workspace-path': 'The package only formats Workspace paths for browser UI; it never constructs model input.',
'packages/util/action-block': 'The package only parses fenced JSON blocks out of answer text; the card experiments that consume it own the model-facing contract.',
'packages/util/values': 'The package only validates, snapshots, compares, freezes, or rejects caller-owned values; consumers own every model-facing use.',
'packages/util/code-language': 'The package only maps a filename suffix to a syntax-highlighting language id; the read consumer owns the persisted hint.',
}
+1 -1
View File
@@ -57,7 +57,6 @@
"@deepseek-ai/dsh-experimental-client-ui-cot-translation": ["./packages/experimental/client-ui-cot-translation/src/index.ts"],
"@deepseek-ai/dsh-experimental-creation-recommendations": ["./packages/experimental/creation-recommendations/src/index.ts"],
"@deepseek-ai/dsh-experimental-creation-recommendations/types": ["./packages/experimental/creation-recommendations/src/types.ts"],
"@deepseek-ai/dsh-experimental-creation-recommendations/action-block": ["./packages/experimental/creation-recommendations/src/action-block.ts"],
"@deepseek-ai/dsh-experimental-client-ui-creation-recommendations": ["./packages/experimental/client-ui-creation-recommendations/src/index.ts"],
"@deepseek-ai/dsh-experimental-client-ui-creation-recommendations/client": ["./packages/experimental/client-ui-creation-recommendations/src/client/index.ts"],
"@deepseek-ai/dsh-experimental-plugin-recommendations": ["./packages/experimental/plugin-recommendations/src/index.ts"],
@@ -371,6 +370,7 @@
"@deepseek-ai/dsh-experimental-inspector/client": ["./packages/experimental/inspector/src/client/index.ts"],
"@deepseek-ai/dsh-experimental-ptc-runtime-python": ["./packages/experimental/ptc-runtime-python/src"],
"@deepseek-ai/dsh-util-crypto": ["./packages/util/crypto/src"],
"@deepseek-ai/dsh-util-action-block": ["./packages/util/action-block/src"],
"@deepseek-ai/dsh-util-values": ["./packages/util/values/src"],
"@deepseek-ai/dsh-util-code-language": ["./packages/util/code-language/src"],
// util/ folders are role-named without the util- prefix their npm names carry,
+1
View File
@@ -326,6 +326,7 @@
{ "path": "./packages/util/crypto" },
{ "path": "./packages/util/deque" },
{ "path": "./packages/util/chunked-list" },
{ "path": "./packages/util/action-block" },
{ "path": "./packages/util/values" },
{ "path": "./packages/util/workspace-path" },
{ "path": "./packages/util/code-language" },