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.
Files changed (67) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +210 -0
  3. package/cordis.patch.yml +6 -0
  4. package/lib/advisor-prompt.d.ts +28 -0
  5. package/lib/advisor-prompt.js +91 -0
  6. package/lib/client/AdvisorCard.d.ts +15 -0
  7. package/lib/client/AdvisorCard.js +60 -0
  8. package/lib/client/AdvisorToolRow.d.ts +40 -0
  9. package/lib/client/AdvisorToolRow.js +52 -0
  10. package/lib/client/PatrolNodeView.d.ts +18 -0
  11. package/lib/client/PatrolNodeView.js +57 -0
  12. package/lib/client/controller.d.ts +161 -0
  13. package/lib/client/controller.js +287 -0
  14. package/lib/client/index.d.ts +20 -0
  15. package/lib/client/index.js +841 -0
  16. package/lib/client/locales.d.ts +54 -0
  17. package/lib/client/locales.js +101 -0
  18. package/lib/client/patrol-chat.d.ts +78 -0
  19. package/lib/client/patrol-chat.js +54 -0
  20. package/lib/client/store.d.ts +11 -0
  21. package/lib/client/store.js +23 -0
  22. package/lib/command.d.ts +7 -0
  23. package/lib/command.js +52 -0
  24. package/lib/config.d.ts +37 -0
  25. package/lib/config.js +83 -0
  26. package/lib/emission-guard.d.ts +32 -0
  27. package/lib/emission-guard.js +74 -0
  28. package/lib/gating.d.ts +8 -0
  29. package/lib/gating.js +48 -0
  30. package/lib/history.d.ts +20 -0
  31. package/lib/history.js +95 -0
  32. package/lib/index.d.ts +25 -0
  33. package/lib/index.js +96 -0
  34. package/lib/llm-call.d.ts +69 -0
  35. package/lib/llm-call.js +483 -0
  36. package/lib/patrol-event.d.ts +88 -0
  37. package/lib/patrol-event.js +68 -0
  38. package/lib/patrol.d.ts +51 -0
  39. package/lib/patrol.js +202 -0
  40. package/lib/prompt-section.d.ts +7 -0
  41. package/lib/prompt-section.js +16 -0
  42. package/lib/settings.d.ts +18 -0
  43. package/lib/settings.js +50 -0
  44. package/lib/tool.d.ts +22 -0
  45. package/lib/tool.js +91 -0
  46. package/package.json +91 -0
  47. package/src/advisor-prompt.ts +102 -0
  48. package/src/client/AdvisorCard.tsx +253 -0
  49. package/src/client/AdvisorToolRow.tsx +83 -0
  50. package/src/client/PatrolNodeView.tsx +85 -0
  51. package/src/client/controller.ts +399 -0
  52. package/src/client/index.ts +100 -0
  53. package/src/client/locales.ts +105 -0
  54. package/src/client/patrol-chat.ts +109 -0
  55. package/src/client/store.ts +29 -0
  56. package/src/command.ts +61 -0
  57. package/src/config.ts +113 -0
  58. package/src/emission-guard.ts +76 -0
  59. package/src/gating.ts +54 -0
  60. package/src/history.ts +102 -0
  61. package/src/index.ts +106 -0
  62. package/src/llm-call.ts +552 -0
  63. package/src/patrol-event.ts +115 -0
  64. package/src/patrol.ts +229 -0
  65. package/src/prompt-section.ts +21 -0
  66. package/src/settings.ts +74 -0
  67. 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
+ }