dsh-advisor-plugin 0.2.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +210 -0
- package/cordis.patch.yml +6 -0
- package/lib/advisor-prompt.d.ts +28 -0
- package/lib/advisor-prompt.js +91 -0
- package/lib/client/AdvisorCard.d.ts +15 -0
- package/lib/client/AdvisorCard.js +60 -0
- package/lib/client/AdvisorToolRow.d.ts +40 -0
- package/lib/client/AdvisorToolRow.js +52 -0
- package/lib/client/PatrolNodeView.d.ts +18 -0
- package/lib/client/PatrolNodeView.js +57 -0
- package/lib/client/controller.d.ts +161 -0
- package/lib/client/controller.js +287 -0
- package/lib/client/index.d.ts +20 -0
- package/lib/client/index.js +841 -0
- package/lib/client/locales.d.ts +54 -0
- package/lib/client/locales.js +101 -0
- package/lib/client/patrol-chat.d.ts +78 -0
- package/lib/client/patrol-chat.js +54 -0
- package/lib/client/store.d.ts +11 -0
- package/lib/client/store.js +23 -0
- package/lib/command.d.ts +7 -0
- package/lib/command.js +52 -0
- package/lib/config.d.ts +37 -0
- package/lib/config.js +83 -0
- package/lib/emission-guard.d.ts +32 -0
- package/lib/emission-guard.js +74 -0
- package/lib/gating.d.ts +8 -0
- package/lib/gating.js +48 -0
- package/lib/history.d.ts +20 -0
- package/lib/history.js +95 -0
- package/lib/index.d.ts +25 -0
- package/lib/index.js +96 -0
- package/lib/llm-call.d.ts +69 -0
- package/lib/llm-call.js +483 -0
- package/lib/patrol-event.d.ts +88 -0
- package/lib/patrol-event.js +68 -0
- package/lib/patrol.d.ts +51 -0
- package/lib/patrol.js +202 -0
- package/lib/prompt-section.d.ts +7 -0
- package/lib/prompt-section.js +16 -0
- package/lib/settings.d.ts +18 -0
- package/lib/settings.js +50 -0
- package/lib/tool.d.ts +22 -0
- package/lib/tool.js +91 -0
- package/package.json +91 -0
- package/src/advisor-prompt.ts +102 -0
- package/src/client/AdvisorCard.tsx +253 -0
- package/src/client/AdvisorToolRow.tsx +83 -0
- package/src/client/PatrolNodeView.tsx +85 -0
- package/src/client/controller.ts +399 -0
- package/src/client/index.ts +100 -0
- package/src/client/locales.ts +105 -0
- package/src/client/patrol-chat.ts +109 -0
- package/src/client/store.ts +29 -0
- package/src/command.ts +61 -0
- package/src/config.ts +113 -0
- package/src/emission-guard.ts +76 -0
- package/src/gating.ts +54 -0
- package/src/history.ts +102 -0
- package/src/index.ts +106 -0
- package/src/llm-call.ts +552 -0
- package/src/patrol-event.ts +115 -0
- package/src/patrol.ts +229 -0
- package/src/prompt-section.ts +21 -0
- package/src/settings.ts +74 -0
- package/src/tool.ts +114 -0
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* patrol-chat —— 巡逻裁决的会话聊天视图贡献(client 半)。
|
|
3
|
+
*
|
|
4
|
+
* 机制(0.1.1 实测反编译确认):
|
|
5
|
+
* - 会话事件流 → conversationEvents 注册的 Definition(事件→业务上下文
|
|
6
|
+
* 状态机)→ buildViewNode 物化聊天节点(kind 为判别键)→ ChatNodeSeat
|
|
7
|
+
* 按节点 kind 派发 keyed `conversation.chat.node` 槽 → 我们注册的
|
|
8
|
+
* key 'advisor-patrol' 卡片渲染。
|
|
9
|
+
* - Definition 匹配不独占:所有命中者各自物化。我们的 match 只认
|
|
10
|
+
* plugin/point 双判别字段,stock 的 messageDefinition 等不会碰
|
|
11
|
+
* log-only 的 hook/invoked,fallback 也只认 surface 事件——因此
|
|
12
|
+
* 该事件在全 UI 里只有这一张卡片,无重复渲染。
|
|
13
|
+
* - 未装载本插件客户端(如 CLI 会话)时事件零渲染,静默无害。
|
|
14
|
+
*
|
|
15
|
+
* 类型策略:conversationEvents 服务在 0.1.1 声明于 dsh-client-runtime
|
|
16
|
+
* (本包未链接、版本间漂移),此处按结构收窄——同 controller.ts 对
|
|
17
|
+
* settingsScope 的做法,版本无关。
|
|
18
|
+
*/
|
|
19
|
+
|
|
20
|
+
import { isPatrolVerdictEvent, type PatrolEventData } from '../patrol-event.js'
|
|
21
|
+
|
|
22
|
+
/** 会话事件的最小结构(client 侧收窄读取) */
|
|
23
|
+
export interface PatrolEventLike {
|
|
24
|
+
readonly type?: unknown
|
|
25
|
+
readonly seq: number
|
|
26
|
+
readonly time: number
|
|
27
|
+
readonly data?: unknown
|
|
28
|
+
readonly location?: unknown
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/** Definition match 返回的身份 */
|
|
32
|
+
interface PatrolMatchResult {
|
|
33
|
+
readonly id: string
|
|
34
|
+
readonly role: 'start'
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/** 引擎喂给 start/buildViewNode 的上下文(最小结构) */
|
|
38
|
+
interface PatrolNodeContext {
|
|
39
|
+
readonly key: string
|
|
40
|
+
readonly id: string
|
|
41
|
+
readonly start?: { readonly location?: unknown } | undefined
|
|
42
|
+
readonly matches?: readonly { readonly location?: unknown }[] | undefined
|
|
43
|
+
readonly state?: PatrolNodeState | undefined
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/** 节点数据 = 事件载荷 + 日志定位(卡片渲染的全部输入) */
|
|
47
|
+
export interface PatrolNodeState extends PatrolEventData {
|
|
48
|
+
readonly seq: number
|
|
49
|
+
readonly time: number
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/** 聊天节点的最终形态(ChatConversationViewNode 的结构子集) */
|
|
53
|
+
export interface PatrolChatViewNode {
|
|
54
|
+
readonly key: string
|
|
55
|
+
readonly kind: 'advisor-patrol'
|
|
56
|
+
readonly id: string
|
|
57
|
+
readonly target: 'chat'
|
|
58
|
+
readonly anchorSeq: number
|
|
59
|
+
readonly location: unknown
|
|
60
|
+
readonly visibility: 'visible'
|
|
61
|
+
readonly data: PatrolNodeState
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/** 事件定位兜底:start 匹配的位置 → 首个匹配的位置 → 未解析 */
|
|
65
|
+
function patrolLocation(context: PatrolNodeContext): unknown {
|
|
66
|
+
return context.start?.location ?? context.matches?.[0]?.location ?? { kind: 'unresolved' }
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/** 巡逻裁决的聊天 Definition(纯工厂,可单测) */
|
|
70
|
+
export function patrolChatDefinition(): {
|
|
71
|
+
kind: 'advisor-patrol'
|
|
72
|
+
target: 'chat'
|
|
73
|
+
match(event: PatrolEventLike): PatrolMatchResult | null
|
|
74
|
+
start(_context: unknown, match: { event: PatrolEventLike }): PatrolNodeState
|
|
75
|
+
update(context: { state: PatrolNodeState }): PatrolNodeState
|
|
76
|
+
buildViewNode(context: PatrolNodeContext): PatrolChatViewNode | null
|
|
77
|
+
} {
|
|
78
|
+
return {
|
|
79
|
+
kind: 'advisor-patrol',
|
|
80
|
+
target: 'chat',
|
|
81
|
+
match: (event) => {
|
|
82
|
+
if (!isPatrolVerdictEvent(event)) return null
|
|
83
|
+
return { id: `advisor-patrol-${event.seq}`, role: 'start' }
|
|
84
|
+
},
|
|
85
|
+
start: (_context, match) => {
|
|
86
|
+
const data = match.event.data as PatrolEventData
|
|
87
|
+
return { ...data, seq: match.event.seq, time: match.event.time }
|
|
88
|
+
},
|
|
89
|
+
update: (context) => context.state,
|
|
90
|
+
buildViewNode: (context) => {
|
|
91
|
+
if (context.state === undefined) return null
|
|
92
|
+
return {
|
|
93
|
+
key: context.key,
|
|
94
|
+
kind: 'advisor-patrol',
|
|
95
|
+
id: context.id,
|
|
96
|
+
target: 'chat',
|
|
97
|
+
anchorSeq: context.state.seq,
|
|
98
|
+
location: patrolLocation(context),
|
|
99
|
+
visibility: 'visible',
|
|
100
|
+
data: context.state,
|
|
101
|
+
}
|
|
102
|
+
},
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/** conversationEvents 服务的结构化最小面(版本无关收窄) */
|
|
107
|
+
export interface ConversationEventsService {
|
|
108
|
+
register(definition: ReturnType<typeof patrolChatDefinition>): () => void
|
|
109
|
+
}
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* store —— 极简快照存储(结构兼容渲染器的 HostObservable:
|
|
3
|
+
* { getSnapshot, subscribe }),避免对官方 client-store 的值导入,
|
|
4
|
+
* 让外部 client 插件保持零运行时外部依赖。
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
export interface SnapshotStore<T> {
|
|
8
|
+
getSnapshot(): T
|
|
9
|
+
subscribe(fn: () => void): () => void
|
|
10
|
+
set(value: T): void
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export function createSnapshotStore<T>(initial: T): SnapshotStore<T> {
|
|
14
|
+
let current = initial
|
|
15
|
+
const listeners = new Set<() => void>()
|
|
16
|
+
return {
|
|
17
|
+
getSnapshot: () => current,
|
|
18
|
+
subscribe: (fn) => {
|
|
19
|
+
listeners.add(fn)
|
|
20
|
+
return () => {
|
|
21
|
+
listeners.delete(fn)
|
|
22
|
+
}
|
|
23
|
+
},
|
|
24
|
+
set: (value) => {
|
|
25
|
+
current = value
|
|
26
|
+
for (const fn of listeners) fn()
|
|
27
|
+
},
|
|
28
|
+
}
|
|
29
|
+
}
|
package/src/command.ts
ADDED
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* command —— `/advisor` 人类命令:报告当前审查模型选择、逐 provider 列出
|
|
3
|
+
* 可配置模型(来自 llm 服务的模型目录)、以及黑名单状态。不产生模型消息。
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import type { Context } from '@deepseek-ai/cordis'
|
|
7
|
+
import type {} from '@deepseek-ai/dsh-commands'
|
|
8
|
+
import { selectionLabel, type Config, type Selection } from './config.js'
|
|
9
|
+
|
|
10
|
+
export function registerAdvisorCommand(
|
|
11
|
+
ctx: Context,
|
|
12
|
+
getConfig: () => Config,
|
|
13
|
+
getSelection: () => Selection | undefined,
|
|
14
|
+
getSettingsInfo: () => string,
|
|
15
|
+
): (() => void) | undefined {
|
|
16
|
+
const commands = ctx.get('commands')
|
|
17
|
+
if (commands === undefined) return undefined
|
|
18
|
+
return commands.register({
|
|
19
|
+
name: 'advisor',
|
|
20
|
+
description: '查看 advisor(审查模型)配置与各 provider 可用模型',
|
|
21
|
+
handler: async () => {
|
|
22
|
+
const selection = getSelection()
|
|
23
|
+
const current = selection === undefined
|
|
24
|
+
? '未配置审查模型——advisor 工具对模型不可见。'
|
|
25
|
+
: `Advisor:${selectionLabel(selection)}`
|
|
26
|
+
|
|
27
|
+
const providers = ctx.llm.listProviders()
|
|
28
|
+
let routesBlock: string
|
|
29
|
+
if (providers.length === 0) {
|
|
30
|
+
routesBlock = '没有已注册的 provider 路由(需要挂载 llm 适配器,如 llm-pi-ai)。'
|
|
31
|
+
} else {
|
|
32
|
+
const perProvider = await Promise.all(providers.map(async p => {
|
|
33
|
+
try {
|
|
34
|
+
const models = await ctx.llm.listModels(p.id)
|
|
35
|
+
const list = models.length === 0 ? '(无模型)' : models.map(m => m.id).join(', ')
|
|
36
|
+
return `- ${p.id}(${p.name}): ${list}`
|
|
37
|
+
} catch {
|
|
38
|
+
return `- ${p.id}(${p.name}): 模型列表获取失败`
|
|
39
|
+
}
|
|
40
|
+
}))
|
|
41
|
+
routesBlock = ['已注册 provider 路由与模型:', ...perProvider].join('\n')
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
const config = getConfig()
|
|
45
|
+
const blocklist = (config.disabledForModels ?? []).length === 0
|
|
46
|
+
? '执行模型黑名单:(空)'
|
|
47
|
+
: `执行模型黑名单:${config.disabledForModels?.join(', ')}`
|
|
48
|
+
|
|
49
|
+
return {
|
|
50
|
+
kind: 'success',
|
|
51
|
+
text: [
|
|
52
|
+
current,
|
|
53
|
+
routesBlock,
|
|
54
|
+
blocklist,
|
|
55
|
+
getSettingsInfo(),
|
|
56
|
+
'在 设置 → 插件 → Advisor 卡片中选择审查模型,或直接编辑 ~/.dsh/settings.yaml 的 advisor: 段。',
|
|
57
|
+
].join('\n'),
|
|
58
|
+
}
|
|
59
|
+
},
|
|
60
|
+
})
|
|
61
|
+
}
|
package/src/config.ts
ADDED
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* config —— schemastery 配置 schema、审查模型选择解析、执行模型黑名单语法。
|
|
3
|
+
*
|
|
4
|
+
* 配置三层合成(rc.6 settings 服务):schema 默认值 → 插件组合层 base →
|
|
5
|
+
* 用户层(~/.dsh/settings.yaml 的 advisor: 段,设置页卡片写入的就是这一层)。
|
|
6
|
+
*
|
|
7
|
+
* `provider` + `model` 两者齐备 advisor 才武装(对应 rpiv 的 "off costs
|
|
8
|
+
* nothing":没有审查模型时工具与提示词段都不注册)。
|
|
9
|
+
*
|
|
10
|
+
* disabledForModels 条目语法(字符串数组,与设置页文案一致):
|
|
11
|
+
* "model" —— 任意 provider 跑该模型都禁用
|
|
12
|
+
* "provider/model" —— 精确路由禁用
|
|
13
|
+
* "provider/model@effort" —— 档位达到 effort 及以上才禁用
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
import z from '@deepseek-ai/schemastery'
|
|
17
|
+
|
|
18
|
+
export interface Config {
|
|
19
|
+
provider?: string
|
|
20
|
+
model?: string
|
|
21
|
+
effort?: string
|
|
22
|
+
disabledForModels?: string[]
|
|
23
|
+
guidelines?: string[]
|
|
24
|
+
patrolEnabled?: boolean
|
|
25
|
+
patrolEverySteps?: number
|
|
26
|
+
patrolImmuneTurns?: number
|
|
27
|
+
investigate?: boolean
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export const Config: z<Config> = z.object({
|
|
31
|
+
provider: z.string().default('').description('审查模型所在的 provider 路由名(ctx.llm 适配器路由,如 example-provider)'),
|
|
32
|
+
model: z.string().default('').description('该路由下的审查模型 id(如 deepseek-v4-pro)'),
|
|
33
|
+
effort: z.string().default('').description('审查模型推理档位;留空走模型默认'),
|
|
34
|
+
disabledForModels: z.array(z.string()).default([]).description('执行模型黑名单:"model" / "provider/model" / "provider/model@minEffort"'),
|
|
35
|
+
guidelines: z.array(z.string()).default([]).description('覆盖默认的升级守则(系统提示段文本,每条一行)'),
|
|
36
|
+
patrolEnabled: z.boolean().default(true).description('巡逻模式:执行中每 N 步自动把会话快照发给审查模型检查是否跑偏,跑偏时注入纠偏'),
|
|
37
|
+
patrolEverySteps: z.number().default(6).min(2).max(500).description('巡逻间隔(模型请求步数);每次巡逻整段会话计费一次审查模型。当保底用可以配大(如 100)'),
|
|
38
|
+
patrolImmuneTurns: z.number().default(3).min(0).max(20).description('纠偏冷却:一次纠偏注入后的 N 个请求内,新的 CORRECTION 降级为只出卡片不注入(STOP 不受冷却限制)'),
|
|
39
|
+
investigate: z.boolean().default(true).description('审查者调查工具:允许审查模型在裁决前用只读工具(工作区内搜索/读文件)亲自核实,建议更有据'),
|
|
40
|
+
})
|
|
41
|
+
|
|
42
|
+
/** 已武装的审查路由;undefined = advisor 关闭 */
|
|
43
|
+
export interface Selection {
|
|
44
|
+
readonly provider: string
|
|
45
|
+
readonly model: string
|
|
46
|
+
readonly effort?: string
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export function resolveSelection(config: Config): Selection | undefined {
|
|
50
|
+
const { provider, model } = config
|
|
51
|
+
if (provider === undefined || provider === '' || model === undefined || model === '') return undefined
|
|
52
|
+
return config.effort === undefined || config.effort === ''
|
|
53
|
+
? { provider, model }
|
|
54
|
+
: { provider, model, effort: config.effort }
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
export function selectionLabel(selection: Selection): string {
|
|
58
|
+
return selection.effort === undefined
|
|
59
|
+
? `${selection.provider}/${selection.model}`
|
|
60
|
+
: `${selection.provider}/${selection.model} (${selection.effort})`
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
// "@minEffort" 阈值比较用的档位序;未知档位排名 NaN 而不是猜
|
|
64
|
+
const EFFORT_LADDER = ['off', 'minimal', 'low', 'medium', 'high', 'xhigh', 'max'] as const
|
|
65
|
+
|
|
66
|
+
function effortRank(effort: string): number {
|
|
67
|
+
const index = EFFORT_LADDER.indexOf(effort as (typeof EFFORT_LADDER)[number])
|
|
68
|
+
return index === -1 ? Number.NaN : index
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
interface BlockRule {
|
|
72
|
+
provider?: string
|
|
73
|
+
model: string
|
|
74
|
+
minEffort?: string
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
function parseRule(entry: string): BlockRule | undefined {
|
|
78
|
+
const parts = entry.split('@')
|
|
79
|
+
const route = parts[0] ?? ''
|
|
80
|
+
const minEffort = parts[1]
|
|
81
|
+
const trimmed = route.trim()
|
|
82
|
+
if (trimmed === '') return undefined
|
|
83
|
+
const slash = trimmed.indexOf('/')
|
|
84
|
+
if (slash === -1) return { model: trimmed, ...(minEffort === undefined ? {} : { minEffort }) }
|
|
85
|
+
return {
|
|
86
|
+
provider: trimmed.slice(0, slash),
|
|
87
|
+
model: trimmed.slice(slash + 1),
|
|
88
|
+
...(minEffort === undefined ? {} : { minEffort }),
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/** 执行路由在当前档位下是否命中黑名单(fail-soft:未知档位/未知阈值不禁用) */
|
|
93
|
+
export function isExecutorBlocked(
|
|
94
|
+
config: Config,
|
|
95
|
+
provider: string | undefined,
|
|
96
|
+
model: string | undefined,
|
|
97
|
+
reasoningEffort: string | undefined,
|
|
98
|
+
): boolean {
|
|
99
|
+
if (provider === undefined || model === undefined) return false
|
|
100
|
+
for (const entry of config.disabledForModels ?? []) {
|
|
101
|
+
const rule = parseRule(entry)
|
|
102
|
+
if (rule === undefined) continue
|
|
103
|
+
if (rule.provider !== undefined && rule.provider !== provider) continue
|
|
104
|
+
if (rule.model !== model) continue
|
|
105
|
+
if (rule.minEffort !== undefined) {
|
|
106
|
+
const current = effortRank(reasoningEffort ?? '')
|
|
107
|
+
const floor = effortRank(rule.minEffort)
|
|
108
|
+
if (Number.isNaN(current) || Number.isNaN(floor) || current < floor) continue
|
|
109
|
+
}
|
|
110
|
+
return true
|
|
111
|
+
}
|
|
112
|
+
return false
|
|
113
|
+
}
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* emission-guard —— 审查建议的防噪闸(对照 oh-my-pi AdvisorEmissionGuard,
|
|
3
|
+
* 其源于生产事故:某会话 advisor 发了 309 次 advise、其中 114 次是
|
|
4
|
+
* "Stop."——防噪规则必须是代码而不是提示词)。
|
|
5
|
+
*
|
|
6
|
+
* 我们对应的问题:巡逻重复发同类 CORRECTION(实测"已咨询过不要重复"
|
|
7
|
+
* 连续纠偏)、无理由的"停止"式裁决。三道闸:
|
|
8
|
+
* 1. 归一化:大小写/NFKC/非字母数字折叠——"Stop."/"*STOP*"/" 停止!"
|
|
9
|
+
* 归一到同一个键;
|
|
10
|
+
* 2. 内容空短语黑名单:归一化后整体匹配(中英双语)——只说结论不给
|
|
11
|
+
* 理由的建议没有信息量,按 unclear 处理不打扰;
|
|
12
|
+
* 3. 会话内精确去重:已注入过的建议再次出现直接丢弃(FIFO 容量上限,
|
|
13
|
+
* 对齐 OMP 的 4096)。
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
/** 会话内建议去重的历史容量(对齐 oh-my-pi) */
|
|
17
|
+
export const ADVICE_DEDUPE_CAPACITY = 4_096
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* 归一化建议文本(纯函数,可单测):小写 + NFKC + 非字母数字连续段折叠为
|
|
21
|
+
* 单空格 + 修剪。\p{L} 覆盖 CJK 表意字符,中文建议同样可归一。
|
|
22
|
+
*/
|
|
23
|
+
export function normalizeAdvice(text: string): string {
|
|
24
|
+
return text
|
|
25
|
+
.toLowerCase()
|
|
26
|
+
.normalize('NFKC')
|
|
27
|
+
.replace(/[^\p{L}\p{N}]+/gu, ' ')
|
|
28
|
+
.trim()
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/** 归一化后整体匹配的"内容空"短语——只给结论不给理由(黑名单保守收录) */
|
|
32
|
+
const CONTENT_FREE_PHRASES: ReadonlySet<string> = new Set([
|
|
33
|
+
// 英文(对齐 OMP 观测到的噪声)
|
|
34
|
+
'stop', 'stop here', 'stop now', 'halt', 'abort',
|
|
35
|
+
'done', 'task done', 'task complete', 'complete', 'finished', 'ok', 'okay', 'ok done',
|
|
36
|
+
'no issue', 'no issues', 'no issue continue', 'no concerns', 'no concern',
|
|
37
|
+
'nothing to add', 'nothing to flag', 'nothing to report', 'no notes',
|
|
38
|
+
'no further input', 'no further input needed', 'no further input required',
|
|
39
|
+
'lgtm', 'looks good', 'looks good to me', 'continue', 'keep going',
|
|
40
|
+
// 中文(patrol 裁决实测可能出现的形式;键必须是归一化后的形态——
|
|
41
|
+
// 标点已折叠为空格,如"没有问题,继续。"→"没有问题 继续")
|
|
42
|
+
'停止', '停止吧', '立即停止', '停下来', '停下', '中止', '终止',
|
|
43
|
+
'已完成', '已完成任务', '任务完成', '任务已完成', '完成', '结束了',
|
|
44
|
+
'好的', '没有问题', '无问题', '没有问题 继续', '无需修改', '不需要修改',
|
|
45
|
+
'继续', '继续保持', '保持现状', '一切正常', '一切正常 继续', '方向正确',
|
|
46
|
+
'没有建议', '无建议', '没有要补充的', '无补充', '没有意见',
|
|
47
|
+
])
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* 归一化后的建议是否"内容空"(纯函数):整体命中黑名单、或归一化后为空。
|
|
51
|
+
* 部分匹配不算——"停止:await 缺失会丢缓冲写"这类带理由的建议不受影响。
|
|
52
|
+
*/
|
|
53
|
+
export function isContentFreeAdvice(normalized: string): boolean {
|
|
54
|
+
if (normalized === '') return true
|
|
55
|
+
return CONTENT_FREE_PHRASES.has(normalized)
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* 会话内建议去重器(每 agent 一份)。返回一个判定函数:输入归一化后的
|
|
60
|
+
* 建议键,首次出现返回 true(接受并记录),重复出现返回 false(丢弃)。
|
|
61
|
+
* FIFO 容量上限防止长会话无界增长。
|
|
62
|
+
*/
|
|
63
|
+
export function createAdviceDeduper(capacity: number = ADVICE_DEDUPE_CAPACITY): (normalized: string) => boolean {
|
|
64
|
+
const seen = new Set<string>()
|
|
65
|
+
const order: string[] = []
|
|
66
|
+
return (normalized: string): boolean => {
|
|
67
|
+
if (seen.has(normalized)) return false
|
|
68
|
+
seen.add(normalized)
|
|
69
|
+
order.push(normalized)
|
|
70
|
+
if (order.length > capacity) {
|
|
71
|
+
const oldest = order.shift()
|
|
72
|
+
if (oldest !== undefined) seen.delete(oldest)
|
|
73
|
+
}
|
|
74
|
+
return true
|
|
75
|
+
}
|
|
76
|
+
}
|
package/src/gating.ts
ADDED
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* gating —— 按 agent 动态可见性。每次解析模型请求时把执行路由对照
|
|
3
|
+
* 黑名单:命中的 agent 在自己的作用域上挂 restrict({deny:['advisor']})
|
|
4
|
+
* (工具 schema 从该 agent 的提示里消失),未命中的解除限制。
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import type { Context } from '@deepseek-ai/cordis'
|
|
8
|
+
import type {} from '@deepseek-ai/dsh-tools'
|
|
9
|
+
import type { Agent } from '@deepseek-ai/dsh-agent'
|
|
10
|
+
import { ADVISOR_TOOL_NAME } from './advisor-prompt.js'
|
|
11
|
+
import { isExecutorBlocked, type Config } from './config.js'
|
|
12
|
+
|
|
13
|
+
export function registerGating(ctx: Context, getConfig: () => Config, hasReviewer: () => boolean) {
|
|
14
|
+
const restrictions = new Map<Agent, () => void>()
|
|
15
|
+
|
|
16
|
+
const lift = (agent: Agent) => {
|
|
17
|
+
const dispose = restrictions.get(agent)
|
|
18
|
+
if (dispose !== undefined) {
|
|
19
|
+
dispose()
|
|
20
|
+
restrictions.delete(agent)
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
const reconcile = (agent: Agent, provider: string | undefined, model: string | undefined, reasoningEffort: string | undefined) => {
|
|
25
|
+
// 未武装时工具根本没注册——无事可藏,且 restrict() 会拒绝未知名
|
|
26
|
+
if (!hasReviewer()) {
|
|
27
|
+
lift(agent)
|
|
28
|
+
return
|
|
29
|
+
}
|
|
30
|
+
const blocked = isExecutorBlocked(getConfig(), provider, model, reasoningEffort)
|
|
31
|
+
if (blocked && !restrictions.has(agent)) {
|
|
32
|
+
restrictions.set(agent, agent.ctx.tools.restrict({ deny: [ADVISOR_TOOL_NAME] }))
|
|
33
|
+
} else if (!blocked) {
|
|
34
|
+
lift(agent)
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
const disposeListener = ctx.on('agent/request', async (payload, next) => {
|
|
39
|
+
const config = await next()
|
|
40
|
+
try {
|
|
41
|
+
reconcile(payload.agent, config.provider, config.model, config.reasoningEffort)
|
|
42
|
+
} catch (error) {
|
|
43
|
+
// 可见性门控绝不破坏用户的 turn
|
|
44
|
+
console.error('[dsh-advisor] gating 失败:', error)
|
|
45
|
+
}
|
|
46
|
+
return config
|
|
47
|
+
})
|
|
48
|
+
|
|
49
|
+
return () => {
|
|
50
|
+
disposeListener()
|
|
51
|
+
for (const dispose of restrictions.values()) dispose()
|
|
52
|
+
restrictions.clear()
|
|
53
|
+
}
|
|
54
|
+
}
|
package/src/history.ts
ADDED
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* history —— 组装发给审查模型的消息列表。
|
|
3
|
+
*
|
|
4
|
+
* 结构(对齐 rpiv-advisor):
|
|
5
|
+
* [工具清单合成消息] + [session.deriveMessages() 的模型可见面]
|
|
6
|
+
*
|
|
7
|
+
* 两个工程要点:
|
|
8
|
+
* 1. deriveMessages() 是 compaction 感知的——压缩摘要按模型实际看到的
|
|
9
|
+
* 面貌转发,而不是重放压缩前的原始历史;
|
|
10
|
+
* 2. 工具清单做"按名排序 + 键排序稳定序列化"——多次 advisor 调用间
|
|
11
|
+
* 字节级一致,命中 DeepSeek 上下文缓存(缓存是整段转发模式的省钱杠杆)。
|
|
12
|
+
*
|
|
13
|
+
* 尾部两条规则原样移植 rpiv:剥掉 in-flight 的 advisor() 调用(孤儿
|
|
14
|
+
* toolCall 会被 provider 拒绝);保证 user 结尾(部分 provider 拒绝
|
|
15
|
+
* assistant 结尾)。
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
import type { Context } from '@deepseek-ai/cordis'
|
|
19
|
+
import type { Agent } from '@deepseek-ai/dsh-agent'
|
|
20
|
+
import type {} from '@deepseek-ai/dsh-tools'
|
|
21
|
+
import { createUserMessage, type Message, type UserMessage } from '@deepseek-ai/dsh-llm'
|
|
22
|
+
import { ADVISOR_TOOL_NAME, MSG_USER_TAIL_NUDGE } from './advisor-prompt.js'
|
|
23
|
+
|
|
24
|
+
// 递归键排序序列化:键序与 V8 插入序无关,同一清单字节级一致
|
|
25
|
+
function stableStringify(value: unknown): string {
|
|
26
|
+
if (value === null || typeof value !== 'object') return JSON.stringify(value)
|
|
27
|
+
if (Array.isArray(value)) {
|
|
28
|
+
return `[${value.map(v => (v === undefined ? 'null' : stableStringify(v))).join(',')}]`
|
|
29
|
+
}
|
|
30
|
+
const obj = value as Record<string, unknown>
|
|
31
|
+
const entries: string[] = []
|
|
32
|
+
for (const k of Object.keys(obj).sort()) {
|
|
33
|
+
const v = obj[k]
|
|
34
|
+
if (v === undefined) continue
|
|
35
|
+
entries.push(`${JSON.stringify(k)}:${stableStringify(v)}`)
|
|
36
|
+
}
|
|
37
|
+
return `{${entries.join(',')}}`
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
interface InventoryCache {
|
|
41
|
+
signature?: string
|
|
42
|
+
message?: UserMessage
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
function createUserText(text: string): UserMessage {
|
|
46
|
+
return createUserMessage({ content: [{ type: 'text', text }], source: { kind: 'user' } })
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
function getInventoryMessage(ctx: Context, cache: InventoryCache, scope: Agent): UserMessage | undefined {
|
|
50
|
+
// 传 agent 作用域:审查者看到的必须是执行模型实际可见的工具面
|
|
51
|
+
// (黑名单 restrict、agent-scoped 工具都体现在这个 scope 里)
|
|
52
|
+
const schemas = ctx.tools.schemas(scope)
|
|
53
|
+
if (schemas.length === 0) return undefined
|
|
54
|
+
const sorted = [...schemas].sort((a, b) => (a.name < b.name ? -1 : a.name > b.name ? 1 : 0))
|
|
55
|
+
const signature = sorted.map(t => t.name).join('|')
|
|
56
|
+
if (cache.signature === signature && cache.message !== undefined) return cache.message
|
|
57
|
+
const block = sorted
|
|
58
|
+
.map(t => `### ${t.name}\n${t.description}\n\nParameters: ${stableStringify(t.parameters)}`)
|
|
59
|
+
.join('\n\n---\n\n')
|
|
60
|
+
const message = createUserText(`## Available Executor Tools\n\n${block}`)
|
|
61
|
+
cache.signature = signature
|
|
62
|
+
cache.message = message
|
|
63
|
+
return message
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
// 剥掉尾部 assistant 消息里 in-flight 的 advisor() toolCall——正是触发本次
|
|
67
|
+
// 咨询的那个调用,还没有配对结果,转发它只会让 provider 拒绝载荷
|
|
68
|
+
function stripInflightAdvisorCall(messages: Message[]): Message[] {
|
|
69
|
+
if (messages.length === 0) return messages
|
|
70
|
+
const last = messages[messages.length - 1]
|
|
71
|
+
if (last === undefined || last.role !== 'assistant') return messages
|
|
72
|
+
const filtered = last.content.filter(
|
|
73
|
+
block => !(block.type === 'tool-call' && block.name === ADVISOR_TOOL_NAME),
|
|
74
|
+
)
|
|
75
|
+
if (filtered.length === last.content.length) return messages
|
|
76
|
+
if (filtered.length === 0) return messages.slice(0, -1)
|
|
77
|
+
return [...messages.slice(0, -1), { ...last, content: filtered } satisfies Message]
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
// 保证 user 结尾:剥除后尾部可能是 assistant(executor 在调用前输出了思考)
|
|
81
|
+
function ensureUserTail(messages: Message[]): Message[] {
|
|
82
|
+
if (messages.length === 0) return messages
|
|
83
|
+
const last = messages[messages.length - 1]
|
|
84
|
+
if (last === undefined || last.role !== 'assistant') return messages
|
|
85
|
+
return [...messages, createUserText(MSG_USER_TAIL_NUDGE)]
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
export function createHistoryBuilder(ctx: Context) {
|
|
89
|
+
// 每个 agent 一份清单缓存(WeakMap 不阻碍临时 agent 回收):工具可见面
|
|
90
|
+
// 是 per-agent 的,作用域不同清单也不同,不能共用同一份缓存
|
|
91
|
+
const caches = new WeakMap<Agent, InventoryCache>()
|
|
92
|
+
return function buildAdvisorMessages(agent: Agent): Message[] {
|
|
93
|
+
const branch = ensureUserTail(stripInflightAdvisorCall(agent.session.deriveMessages()))
|
|
94
|
+
let cache = caches.get(agent)
|
|
95
|
+
if (cache === undefined) {
|
|
96
|
+
cache = {}
|
|
97
|
+
caches.set(agent, cache)
|
|
98
|
+
}
|
|
99
|
+
const inventory = getInventoryMessage(ctx, cache, agent)
|
|
100
|
+
return inventory === undefined ? branch : [inventory, ...branch]
|
|
101
|
+
}
|
|
102
|
+
}
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* dsh-advisor —— advisor 策略模式的 DSH 实现(host 半入口)。
|
|
3
|
+
*
|
|
4
|
+
* 机制(rpiv-advisor 移植):
|
|
5
|
+
* 1. 执行模型拿到零参数 `advisor` 工具;调用即把整段会话分支
|
|
6
|
+
* (compaction 感知)+ 工具清单转发给配置的更强审查模型;
|
|
7
|
+
* 2. 审查模型按 plan / correction / stop signal 三选一契约回文,
|
|
8
|
+
* 作为工具结果交还执行模型——不进人类可见对话流;
|
|
9
|
+
* 3. 组合关系:选了审查模型 ⇔ 工具 + 升级守则提示段注册在案
|
|
10
|
+
* (未武装的 advisor 零提示词成本);
|
|
11
|
+
* 4. agent/request 监听每次解析的执行路由,命中 disabledForModels
|
|
12
|
+
* 黑名单时对那个 agent 作用域挂 restrict 隐藏工具;
|
|
13
|
+
* 5. settings 服务就绪后配置接入 'advisor' 命名空间(用户层在
|
|
14
|
+
* ~/.dsh/settings.yaml 的 advisor: 段;设置页卡片实时读写)。
|
|
15
|
+
*
|
|
16
|
+
* 服务时序:tools/llm/systemPrompt 是硬依赖(inject 导出);settings 与
|
|
17
|
+
* commands 是软依赖——用 ctx.inject 等待就绪,apply 时刻 ctx.get
|
|
18
|
+
* 拿到 undefined 只说明服务尚未激活,不代表不存在。
|
|
19
|
+
*/
|
|
20
|
+
|
|
21
|
+
import type { Context } from '@deepseek-ai/cordis'
|
|
22
|
+
import type {} from '@deepseek-ai/dsh-tools'
|
|
23
|
+
import { Config as ConfigSchema, resolveSelection, type Config, type Selection } from './config.js'
|
|
24
|
+
import { registerAdvisorCommand } from './command.js'
|
|
25
|
+
import { registerGating } from './gating.js'
|
|
26
|
+
import { createHistoryBuilder } from './history.js'
|
|
27
|
+
import { registerPatrol } from './patrol.js'
|
|
28
|
+
import { registerAdvisorSection } from './prompt-section.js'
|
|
29
|
+
import { createAdvisorTool } from './tool.js'
|
|
30
|
+
import { wireSettings } from './settings.js'
|
|
31
|
+
|
|
32
|
+
export const name = 'dsh-advisor'
|
|
33
|
+
export const inject = ['tools', 'llm', 'systemPrompt']
|
|
34
|
+
export { ConfigSchema as Config }
|
|
35
|
+
|
|
36
|
+
export function apply(ctx: Context, config: Config) {
|
|
37
|
+
const state: { config: Config, selection: Selection | undefined, settingsInfo: string } = {
|
|
38
|
+
config,
|
|
39
|
+
selection: resolveSelection(config),
|
|
40
|
+
settingsInfo: 'settings 服务未接入',
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
const buildMessages = createHistoryBuilder(ctx)
|
|
44
|
+
let registration: (() => void) | undefined
|
|
45
|
+
|
|
46
|
+
// 武装/解除的总闸:工具与提示段同生同灭
|
|
47
|
+
const reconcileRegistration = () => {
|
|
48
|
+
registration?.()
|
|
49
|
+
registration = undefined
|
|
50
|
+
if (state.selection === undefined) return
|
|
51
|
+
const disposeTool = ctx.tools.register(createAdvisorTool(ctx, () => state.selection, buildMessages, () => state.config))
|
|
52
|
+
const disposeSection = registerAdvisorSection(ctx, state.config)
|
|
53
|
+
registration = () => {
|
|
54
|
+
disposeTool()
|
|
55
|
+
disposeSection()
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
const disposeGating = registerGating(ctx, () => state.config, () => state.selection !== undefined)
|
|
60
|
+
|
|
61
|
+
// 巡逻模式:按间隔自动检查执行是否跑偏并注入纠偏(未武装/被禁用时内部 no-op)
|
|
62
|
+
const disposePatrol = registerPatrol(ctx, {
|
|
63
|
+
getConfig: () => state.config,
|
|
64
|
+
getSelection: () => state.selection,
|
|
65
|
+
buildMessages,
|
|
66
|
+
})
|
|
67
|
+
if (state.config.patrolEnabled !== false) {
|
|
68
|
+
console.log(`[dsh-advisor] 巡逻模式开启:每 ${state.config.patrolEverySteps ?? 6} 步自动检查(间隔下限 90s)`)
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
// /advisor 命令:commands 服务就绪后注册(fiber 卸载自动清理)
|
|
72
|
+
ctx.inject(['commands'], (commandsCtx) => {
|
|
73
|
+
const dispose = registerAdvisorCommand(commandsCtx, () => state.config, () => state.selection, () => state.settingsInfo)
|
|
74
|
+
if (dispose !== undefined) commandsCtx.effect(() => dispose, 'dsh-advisor: /advisor command')
|
|
75
|
+
})
|
|
76
|
+
|
|
77
|
+
// 设置接入:settings 服务就绪后把 'advisor' 命名空间接进来并活编辑。
|
|
78
|
+
// 注意传插件根 ctx:rc.6 cordis 的 Service 方法绑定调用者 fiber,注册
|
|
79
|
+
// effect 挂在 inject 的临时 fiber 上会在 fiber 回收时静默注销命名空间。
|
|
80
|
+
ctx.inject(['settings'], () => {
|
|
81
|
+
const wiring = wireSettings(ctx, state, reconcileRegistration)
|
|
82
|
+
state.settingsInfo = wiring.info
|
|
83
|
+
reconcileRegistration()
|
|
84
|
+
console.log(`[dsh-advisor] ${wiring.info}`)
|
|
85
|
+
// 设置解析后的最终武装状态(apply 时刻的那条"未武装"只是组合层初值)
|
|
86
|
+
if (state.selection === undefined) {
|
|
87
|
+
console.log('[dsh-advisor] 设置解析完成:仍未武装(advisor: 段缺 provider/model)——工具不注册')
|
|
88
|
+
} else {
|
|
89
|
+
console.log(`[dsh-advisor] 设置解析完成:已武装 ${state.selection.provider}/${state.selection.model}${state.selection.effort === undefined ? '' : ` (${state.selection.effort})`}——advisor 工具已注册`)
|
|
90
|
+
}
|
|
91
|
+
})
|
|
92
|
+
|
|
93
|
+
reconcileRegistration()
|
|
94
|
+
|
|
95
|
+
if (state.selection === undefined) {
|
|
96
|
+
console.log('[dsh-advisor] 未武装:未配置审查模型(provider+model),advisor 工具不注册')
|
|
97
|
+
} else {
|
|
98
|
+
console.log(`[dsh-advisor] 已武装:${state.selection.provider}/${state.selection.model}${state.selection.effort === undefined ? '' : ` (${state.selection.effort})`}`)
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
ctx.effect(() => () => {
|
|
102
|
+
registration?.()
|
|
103
|
+
disposeGating()
|
|
104
|
+
disposePatrol()
|
|
105
|
+
})
|
|
106
|
+
}
|