@twinsearth/dsh-master 3.2.9 → 3.3.0

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 (102) hide show
  1. package/README.md +39 -6
  2. package/client.js +51 -1
  3. package/dist-host/cost/attribution.js +183 -0
  4. package/dist-host/cost/audit.js +163 -0
  5. package/dist-host/cost/budget.js +211 -0
  6. package/dist-host/cost/bus.js +95 -0
  7. package/dist-host/cost/cert-chain.js +100 -0
  8. package/dist-host/cost/experiment.js +132 -0
  9. package/dist-host/cost/gate.js +189 -0
  10. package/dist-host/cost/host-surface.js +80 -0
  11. package/dist-host/cost/index.js +312 -0
  12. package/dist-host/cost/ledger-report.js +193 -0
  13. package/dist-host/cost/ledger.js +401 -0
  14. package/dist-host/cost/metrics.js +198 -0
  15. package/dist-host/cost/milestones.js +171 -0
  16. package/dist-host/cost/modules/index.js +5 -0
  17. package/dist-host/cost/modules/manifests.js +130 -0
  18. package/dist-host/cost/policy.js +276 -0
  19. package/dist-host/cost/report.js +156 -0
  20. package/dist-host/cost/reuse-report.js +128 -0
  21. package/dist-host/cost/rsi.js +367 -0
  22. package/dist-host/cost/toolface.js +147 -0
  23. package/dist-host/cost/types.js +31 -0
  24. package/dist-host/kernel.js +38 -11
  25. package/dist-host/plugins/cost-optimizer.js +277 -27
  26. package/dist-host/plugins/superagent-router.js +89 -2
  27. package/dist-host/plugins/superagent-worker.js +1 -1
  28. package/dist-host/superagent/router.js +1 -1
  29. package/dist-host/superagent/types.js +8 -0
  30. package/dist-worker/agent-loop.js +449 -7
  31. package/dist-worker/baseline-fit.js +66 -0
  32. package/dist-worker/budget-guard.js +93 -0
  33. package/dist-worker/ccs.js +99 -0
  34. package/dist-worker/cost-hooks.js +115 -0
  35. package/dist-worker/cost-modules.js +330 -0
  36. package/dist-worker/cost-spi.js +3 -0
  37. package/dist-worker/edg.js +372 -0
  38. package/dist-worker/gdr.js +159 -0
  39. package/dist-worker/index.js +80 -2
  40. package/dist-worker/llm-http.js +20 -0
  41. package/dist-worker/sink-http.js +1 -0
  42. package/dist-worker/spill.js +115 -0
  43. package/dist-worker/tool-batch.js +66 -0
  44. package/dist-worker/tool-compact.js +275 -0
  45. package/dist-worker/tool-disclosure.js +90 -0
  46. package/dist-worker/tools.js +16 -0
  47. package/docs/ARCHITECTURE.md +32 -11
  48. package/docs/CHANGELOG.md +60 -0
  49. package/docs/DESIGN-v3.3.0-cost-optimization.md +1053 -0
  50. package/docs/EXPERIMENT-M5-real-ab.md +82 -0
  51. package/docs/RELEASE-v3.2.10.md +32 -0
  52. package/docs/ROADMAP.md +3 -3
  53. package/docs/SECURITY.md +9 -9
  54. package/docs/SUPERAGENT.md +5 -5
  55. package/docs/THIRD-PARTY.md +87 -0
  56. package/docs/WORKER-AGENT-LOOP.md +1 -1
  57. package/package.json +15 -3
  58. package/src/cost/attribution.ts +186 -0
  59. package/src/cost/audit.ts +170 -0
  60. package/src/cost/budget.ts +226 -0
  61. package/src/cost/bus.ts +97 -0
  62. package/src/cost/cert-chain.ts +102 -0
  63. package/src/cost/experiment.ts +136 -0
  64. package/src/cost/gate.ts +191 -0
  65. package/src/cost/host-surface.ts +97 -0
  66. package/src/cost/index.ts +316 -0
  67. package/src/cost/ledger-report.ts +186 -0
  68. package/src/cost/ledger.ts +385 -0
  69. package/src/cost/metrics.ts +193 -0
  70. package/src/cost/milestones.ts +170 -0
  71. package/src/cost/modules/index.ts +13 -0
  72. package/src/cost/modules/manifests.ts +129 -0
  73. package/src/cost/policy.ts +276 -0
  74. package/src/cost/report.ts +158 -0
  75. package/src/cost/reuse-report.ts +131 -0
  76. package/src/cost/rsi.ts +364 -0
  77. package/src/cost/toolface.ts +151 -0
  78. package/src/cost/types.ts +298 -0
  79. package/src/kernel.ts +37 -11
  80. package/src/plugins/cost-optimizer.ts +271 -26
  81. package/src/plugins/superagent-router.ts +83 -2
  82. package/src/plugins/superagent-worker.ts +1 -1
  83. package/src/superagent/router.ts +1 -1
  84. package/src/superagent/types.ts +70 -0
  85. package/worker/agent-loop.ts +444 -7
  86. package/worker/baseline-fit.ts +80 -0
  87. package/worker/budget-guard.ts +156 -0
  88. package/worker/ccs.ts +202 -0
  89. package/worker/cost-hooks.ts +133 -0
  90. package/worker/cost-modules.ts +356 -0
  91. package/worker/cost-spi.ts +283 -0
  92. package/worker/edg.ts +424 -0
  93. package/worker/gdr.ts +233 -0
  94. package/worker/index.ts +84 -3
  95. package/worker/llm-http.ts +18 -0
  96. package/worker/sink-http.ts +5 -2
  97. package/worker/spill.ts +133 -0
  98. package/worker/tool-batch.ts +124 -0
  99. package/worker/tool-compact.ts +317 -0
  100. package/worker/tool-disclosure.ts +131 -0
  101. package/worker/tools.ts +553 -531
  102. package/worker/types.ts +191 -0
package/worker/edg.ts ADDED
@@ -0,0 +1,424 @@
1
+ // SPDX-License-Identifier: AGPL-3.0-or-later
2
+ // Copyright (c) 2026 dsh-Master contributors — see LICENSE-CORE.
3
+
4
+ /**
5
+ * 3.3.0 · B1 `edg`(可执行决策图,设计 §7)。
6
+ *
7
+ * 一条边 = 「精确动作 + 确定性参数来源 + 守卫」,这是 HarnessMM 的核心资产:
8
+ * 慢模型只需在**作者步骤**写一次,之后由守卫命中直接复用。
9
+ *
10
+ * 硬约束(写死在实现里):
11
+ * * `schemaHash` 与当前工具 schema 不一致 ⇒ **整图作废**(不得复用旧决策);
12
+ * * 参数只能来自回执的**确定性提取**(literal / regex / jsonpath)——
13
+ * 解析不出来就直接判定该边不可用(**禁止模型幻觉参数**);
14
+ * * `destructive: true` 的边**默认不入池**(validate 直接拒);
15
+ * * 生命周期 `draft → validated → active → exhausted → archived`,非法跃迁一律拒绝;
16
+ * * 图来源两种:慢模型作者步骤(`parseDraftFromModel`)或人工/AI 预置模板库。
17
+ *
18
+ * 作者成本**单独记**:`phase:'authoring'` + `account:'governance'`(由宿主记账,见 cost-optimizer)。
19
+ */
20
+
21
+ import crypto from 'node:crypto'
22
+
23
+ export type EdgState = 'draft' | 'validated' | 'active' | 'exhausted' | 'archived'
24
+ export type ArgSourceKind = 'literal' | 'regex' | 'jsonpath'
25
+ export type GuardKind = 'regex' | 'jsonpath' | 'equals'
26
+ export type EdgSource = 'author' | 'template'
27
+
28
+ export interface EdgArgSource {
29
+ arg: string
30
+ from: ArgSourceKind
31
+ expr: string
32
+ }
33
+
34
+ export interface EdgGuard {
35
+ kind: GuardKind
36
+ expr: string
37
+ negate?: boolean
38
+ }
39
+
40
+ export interface EdgEdge {
41
+ id: string
42
+ toolName: string
43
+ schemaHash: string
44
+ argSource: EdgArgSource[]
45
+ guard: EdgGuard
46
+ destructive: boolean
47
+ dependsOn?: string[]
48
+ }
49
+
50
+ export interface EdgGraph {
51
+ id: string
52
+ taskId: string
53
+ createdAt: number
54
+ source: EdgSource
55
+ state: EdgState
56
+ model?: string
57
+ /** 冻结后才可被 enforce 引用(§7 B4:RSI 产出候选,冻结后上线)。 */
58
+ frozen: boolean
59
+ edges: EdgEdge[]
60
+ /** 作者步骤消耗的 token(记 governance,不算节省) */
61
+ authorTokens: number
62
+ reason?: string
63
+ }
64
+
65
+ /** 当前工具 schema 的稳定哈希(tools 列表变化即整图作废)。 */
66
+ export function schemaHashOf(toolName: string, parameters: unknown, description?: string): string {
67
+ const body = JSON.stringify({ toolName, parameters: parameters ?? {}, description: description ?? '' })
68
+ return `sha256:${crypto.createHash('sha256').update(body).digest('hex').slice(0, 16)}`
69
+ }
70
+
71
+ const tokensOf = (text: string): number => (text.length > 0 ? Math.ceil(text.length / 4) : 0)
72
+
73
+ /* ------------------------------------------------------------------ *
74
+ * 确定性提取(禁止幻觉参数)
75
+ * ------------------------------------------------------------------ */
76
+
77
+ /** 极简 JSONPath:`$.a.b` / `$.a[0].b`(够用即止,不做完整实现)。 */
78
+ export function jsonPathGet(value: unknown, expr: string): unknown {
79
+ const path = String(expr ?? '').trim()
80
+ if (!path.startsWith('$')) return undefined
81
+ const segments: Array<string | number> = []
82
+ const body = path.slice(1)
83
+ const re = /\.([A-Za-z_$][\w$]*)|\[(\d+)\]/gu
84
+ let match: RegExpExecArray | null
85
+ let consumed = 0
86
+ while ((match = re.exec(body)) !== null) {
87
+ if (match.index !== consumed) return undefined
88
+ consumed = match.index + match[0].length
89
+ segments.push(match[2] === undefined ? match[1] : Number(match[2]))
90
+ }
91
+ if (consumed !== body.length) return undefined
92
+ let current: unknown = value
93
+ for (const segment of segments) {
94
+ if (current === null || current === undefined) return undefined
95
+ if (typeof segment === 'number') {
96
+ if (!Array.isArray(current)) return undefined
97
+ current = current[segment]
98
+ } else {
99
+ if (typeof current !== 'object') return undefined
100
+ current = (current as Record<string, unknown>)[segment]
101
+ }
102
+ }
103
+ return current
104
+ }
105
+
106
+ /** 把回执文本按需解析成 JSON(失败返回 undefined,不猜)。 */
107
+ function asJson(text: string): unknown {
108
+ const raw = String(text ?? '')
109
+ try { return JSON.parse(raw) } catch { /* 继续尝试提取 JSON 片段 */ }
110
+ // 回执常带前缀(如 shell 的 STDOUT: 或说明行):在**确定性位置**截取首个 { 到末个 }
111
+ // 再解析。这仍然是确定性的文本提取,不是猜测。
112
+ const start = raw.indexOf('{')
113
+ const end = raw.lastIndexOf('}')
114
+ if (start < 0 || end <= start) return undefined
115
+ try { return JSON.parse(raw.slice(start, end + 1)) } catch { return undefined }
116
+ }
117
+
118
+ /**
119
+ * 解析单个 argSource。**失败返回 undefined**(调用方据此判定该边不可用),
120
+ * 绝不回退到"模型给的参数"或默认值。
121
+ */
122
+ export function resolveArg(source: EdgArgSource, receiptText: string): unknown {
123
+ const expr = String(source?.expr ?? '')
124
+ if (source?.from === 'literal') {
125
+ // literal 就是字面量本身(不做变量替换,避免"看起来像模板"的幻觉空间)
126
+ return expr
127
+ }
128
+ if (source?.from === 'regex') {
129
+ // expr 形如 `/(?<v>...)/` 或裸正则;取第一个捕获组,没有捕获组则取整个匹配。
130
+ const trimmed = expr.trim()
131
+ const body = trimmed.startsWith('/') && trimmed.lastIndexOf('/') > 0
132
+ ? trimmed.slice(1, trimmed.lastIndexOf('/'))
133
+ : trimmed
134
+ let re: RegExp
135
+ try { re = new RegExp(body, 'u') } catch { return undefined }
136
+ const match = re.exec(receiptText)
137
+ if (!match) return undefined
138
+ return match.groups?.value ?? match[1] ?? match[0]
139
+ }
140
+ if (source?.from === 'jsonpath') {
141
+ const json = asJson(receiptText)
142
+ if (json === undefined) return undefined
143
+ return jsonPathGet(json, expr)
144
+ }
145
+ return undefined
146
+ }
147
+
148
+ /** 解析整条边的参数;任一参数解析失败 ⇒ null(该边不可用于本次回执)。 */
149
+ export function resolveEdgeArgs(edge: EdgEdge, receiptText: string): Record<string, unknown> | null {
150
+ const args: Record<string, unknown> = {}
151
+ for (const source of edge.argSource) {
152
+ const value = resolveArg(source, receiptText)
153
+ if (value === undefined) return null
154
+ args[source.arg] = value
155
+ }
156
+ return args
157
+ }
158
+
159
+ /* ------------------------------------------------------------------ *
160
+ * 守卫
161
+ * ------------------------------------------------------------------ */
162
+
163
+ /** 守卫求值:命中返回 true。表达式非法 ⇒ false(宁可不命中,也不误替换)。 */
164
+ export function evaluateGuard(guard: EdgGuard, receiptText: string): boolean {
165
+ if (!guard || typeof guard !== 'object') return false
166
+ let result = false
167
+ if (guard.kind === 'regex') {
168
+ const trimmed = String(guard.expr ?? '').trim()
169
+ const body = trimmed.startsWith('/') && trimmed.lastIndexOf('/') > 0
170
+ ? trimmed.slice(1, trimmed.lastIndexOf('/'))
171
+ : trimmed
172
+ try { result = new RegExp(body, 'u').test(receiptText) } catch { return false }
173
+ } else if (guard.kind === 'equals') {
174
+ result = receiptText.trim() === String(guard.expr ?? '').trim()
175
+ } else if (guard.kind === 'jsonpath') {
176
+ const json = asJson(receiptText)
177
+ if (json === undefined) return false
178
+ const value = jsonPathGet(json, String(guard.expr ?? ''))
179
+ result = value !== undefined && value !== null && value !== false && value !== '' && value !== 0
180
+ } else {
181
+ return false
182
+ }
183
+ return guard.negate === true ? !result : result
184
+ }
185
+
186
+ /* ------------------------------------------------------------------ *
187
+ * 图 store 与生命周期
188
+ * ------------------------------------------------------------------ */
189
+
190
+ const LEGAL_TRANSITIONS: Record<EdgState, EdgState[]> = {
191
+ draft: ['validated', 'archived'],
192
+ validated: ['active', 'archived'],
193
+ active: ['exhausted', 'archived'],
194
+ exhausted: ['archived', 'active'],
195
+ archived: ['active'],
196
+ }
197
+
198
+ export interface ValidateOptions {
199
+ /** 当前工具 schema 哈希表:工具名 → schemaHash。 */
200
+ schemas: Record<string, string>
201
+ /** 允许入池的工具(默认全部)。 */
202
+ allowedTools?: string[]
203
+ /** 允许破坏性动作入池(默认 false)。 */
204
+ allowDestructive?: boolean
205
+ maxEdges?: number
206
+ }
207
+
208
+ export interface EdgStore {
209
+ addDraft(input: { taskId: string; edges: Array<Partial<EdgEdge> & { id?: string }>; source?: EdgSource; model?: string; authorText?: string }): EdgGraph
210
+ validate(graphId: string, options: ValidateOptions): { ok: boolean; state: EdgState; reason?: string }
211
+ activate(graphId: string): { ok: boolean; state: EdgState; reason?: string }
212
+ exhaust(graphId: string, reason?: string): { ok: boolean; state: EdgState }
213
+ archive(graphId: string): { ok: boolean; state: EdgState }
214
+ get(graphId: string): EdgGraph | null
215
+ list(state?: EdgState): EdgGraph[]
216
+ /** schema 变化的整图作废:返回被作废的图 id 列表。 */
217
+ invalidateBySchema(schemas: Record<string, string>): string[]
218
+ /** 作者成本合计(记 governance 用)。 */
219
+ authorCost(): { graphs: number; authorTokens: number }
220
+ reset(): void
221
+ }
222
+
223
+ export function createEdgStore(options: { now?: () => number; idFactory?: () => string } = {}): EdgStore {
224
+ const now = typeof options.now === 'function' ? options.now : () => Date.now()
225
+ let seq = 0
226
+ const idFactory = typeof options.idFactory === 'function' ? options.idFactory : () => `edg_${now().toString(36)}_${(seq += 1).toString(36)}`
227
+ const graphs = new Map<string, EdgGraph>()
228
+
229
+ const clone = (graph: EdgGraph): EdgGraph => Object.freeze({ ...graph, edges: graph.edges.map((edge) => Object.freeze({ ...edge, argSource: edge.argSource.map((item) => ({ ...item })) })) }) as EdgGraph
230
+
231
+ function transition(graph: EdgGraph, to: EdgState, reason?: string): { ok: boolean; state: EdgState; reason?: string } {
232
+ if (!LEGAL_TRANSITIONS[graph.state].includes(to)) {
233
+ return { ok: false, state: graph.state, reason: `illegal-transition:${graph.state}->${to}` }
234
+ }
235
+ graph.state = to
236
+ if (reason !== undefined) graph.reason = reason
237
+ return { ok: true, state: graph.state }
238
+ }
239
+
240
+ function addDraft(input: { taskId: string; edges: Array<Partial<EdgEdge> & { id?: string }>; source?: EdgSource; model?: string; authorText?: string }): EdgGraph {
241
+ const edges = (Array.isArray(input?.edges) ? input.edges : []).map((edge, index) => Object.freeze({
242
+ id: String(edge?.id ?? `edge_${index + 1}`),
243
+ toolName: String(edge?.toolName ?? ''),
244
+ schemaHash: String(edge?.schemaHash ?? ''),
245
+ argSource: Array.isArray(edge?.argSource) ? edge.argSource.map((item) => Object.freeze({ arg: String(item?.arg ?? ''), from: String(item?.from ?? 'literal') as ArgSourceKind, expr: String(item?.expr ?? '') })) : [],
246
+ guard: Object.freeze({
247
+ kind: String(edge?.guard?.kind ?? 'regex') as GuardKind,
248
+ expr: String(edge?.guard?.expr ?? ''),
249
+ negate: edge?.guard?.negate === true,
250
+ }),
251
+ destructive: edge?.destructive === true,
252
+ dependsOn: Array.isArray(edge?.dependsOn) ? edge.dependsOn.map((item) => String(item)) : undefined,
253
+ }))
254
+ const graph: EdgGraph = {
255
+ id: idFactory(),
256
+ taskId: String(input?.taskId ?? ''),
257
+ createdAt: now(),
258
+ source: input?.source === 'template' ? 'template' : 'author',
259
+ state: 'draft',
260
+ model: input?.model === undefined ? undefined : String(input.model),
261
+ frozen: false,
262
+ edges,
263
+ authorTokens: tokensOf(String(input?.authorText ?? '')),
264
+ }
265
+ graphs.set(graph.id, graph)
266
+ return clone(graph)
267
+ }
268
+
269
+ function validate(graphId: string, opts: ValidateOptions): { ok: boolean; state: EdgState; reason?: string } {
270
+ const graph = graphs.get(String(graphId))
271
+ if (!graph) return { ok: false, state: 'draft', reason: 'graph-not-found' }
272
+ const allowed = Array.isArray(opts?.allowedTools) && opts.allowedTools.length > 0 ? new Set(opts.allowedTools) : null
273
+ const maxEdges = Math.max(1, Number(opts?.maxEdges ?? 32) || 32)
274
+ const ids = new Set(graph.edges.map((edge) => edge.id))
275
+ for (const edge of graph.edges) {
276
+ if (!edge.toolName) return { ok: false, state: graph.state, reason: 'edge-missing-tool' }
277
+ if (allowed && !allowed.has(edge.toolName)) return { ok: false, state: graph.state, reason: `tool-not-allowed:${edge.toolName}` }
278
+ // schema 校验:拿不到当前哈希 ⇒ 视为不一致(fail-closed)
279
+ const current = opts?.schemas?.[edge.toolName]
280
+ if (!current || current !== edge.schemaHash) return { ok: false, state: graph.state, reason: `schema-mismatch:${edge.toolName}` }
281
+ for (const source of edge.argSource) {
282
+ if (!source.arg) return { ok: false, state: graph.state, reason: 'arg-source-missing-arg' }
283
+ if (!['literal', 'regex', 'jsonpath'].includes(source.from)) return { ok: false, state: graph.state, reason: `arg-source-invalid:${source.from}` }
284
+ }
285
+ if (!['regex', 'jsonpath', 'equals'].includes(edge.guard.kind)) return { ok: false, state: graph.state, reason: `guard-kind-invalid:${edge.guard.kind}` }
286
+ // 破坏性动作默认不入池
287
+ if (edge.destructive && opts?.allowDestructive !== true) return { ok: false, state: graph.state, reason: `destructive-not-allowed:${edge.toolName}` }
288
+ for (const dep of edge.dependsOn ?? []) {
289
+ if (!ids.has(dep)) return { ok: false, state: graph.state, reason: `dependency-missing:${dep}` }
290
+ }
291
+ }
292
+ if (graph.edges.length === 0) return { ok: false, state: graph.state, reason: 'graph-empty' }
293
+ if (graph.edges.length > maxEdges) return { ok: false, state: graph.state, reason: 'graph-too-large' }
294
+ return transition(graph, 'validated')
295
+ }
296
+
297
+ function activate(graphId: string) {
298
+ const graph = graphs.get(String(graphId))
299
+ if (!graph) return { ok: false, state: 'draft' as EdgState, reason: 'graph-not-found' }
300
+ return transition(graph, 'active')
301
+ }
302
+
303
+ function exhaust(graphId: string, reason?: string) {
304
+ const graph = graphs.get(String(graphId))
305
+ if (!graph) return { ok: false, state: 'draft' as EdgState }
306
+ return transition(graph, 'exhausted', reason)
307
+ }
308
+
309
+ function archive(graphId: string) {
310
+ const graph = graphs.get(String(graphId))
311
+ if (!graph) return { ok: false, state: 'draft' as EdgState }
312
+ return transition(graph, 'archived')
313
+ }
314
+
315
+ /** schema 变化 ⇒ 整图作废(有边的 schemaHash 与当前不符即整图归档)。 */
316
+ function invalidateBySchema(schemas: Record<string, string>): string[] {
317
+ const invalidated: string[] = []
318
+ for (const graph of graphs.values()) {
319
+ if (graph.state === 'archived') continue
320
+ const bad = graph.edges.find((edge) => schemas?.[edge.toolName] !== edge.schemaHash)
321
+ if (!bad) continue
322
+ graph.state = 'archived'
323
+ graph.reason = `schema-drift:${bad.toolName}`
324
+ invalidated.push(graph.id)
325
+ }
326
+ return invalidated
327
+ }
328
+
329
+ function get(graphId: string): EdgGraph | null {
330
+ const graph = graphs.get(String(graphId))
331
+ return graph ? clone(graph) : null
332
+ }
333
+
334
+ function list(state?: EdgState): EdgGraph[] {
335
+ return [...graphs.values()].filter((graph) => (state === undefined ? true : graph.state === state)).map((graph) => clone(graph))
336
+ }
337
+
338
+ function authorCost(): { graphs: number; authorTokens: number } {
339
+ let authorTokens = 0
340
+ let count = 0
341
+ for (const graph of graphs.values()) {
342
+ if (graph.source !== 'author') continue
343
+ count += 1
344
+ authorTokens += graph.authorTokens
345
+ }
346
+ return { graphs: count, authorTokens }
347
+ }
348
+
349
+ function reset(): void {
350
+ graphs.clear()
351
+ }
352
+
353
+ return Object.freeze({ addDraft, validate, activate, exhaust, archive, get, list, invalidateBySchema, authorCost, reset })
354
+ }
355
+
356
+ /* ------------------------------------------------------------------ *
357
+ * 作者步骤:从慢模型输出里抽候选图(确定性解析,不猜)
358
+ * ------------------------------------------------------------------ */
359
+
360
+ /**
361
+ * 从模型输出中抽取 `{ "edges": [...] }` JSON 块。
362
+ * 只接受 `edges` 数组里的合法字段;解析不出来返回 null(**不生成空图、不猜参数**)。
363
+ */
364
+ export function parseDraftFromModel(text: string): { edges: Array<Partial<EdgEdge> & { id?: string }>; authorText: string } | null {
365
+ const raw = String(text ?? '')
366
+ if (raw.trim() === '') return null
367
+ const blocks = [...raw.matchAll(/```(?:json)?\s*([\s\S]*?)```/gu)].map((match) => match[1])
368
+ const candidates = blocks.length > 0 ? blocks : [raw]
369
+ for (const candidate of candidates) {
370
+ const start = candidate.indexOf('{')
371
+ const end = candidate.lastIndexOf('}')
372
+ if (start < 0 || end <= start) continue
373
+ let parsed: unknown
374
+ try { parsed = JSON.parse(candidate.slice(start, end + 1)) } catch { continue }
375
+ if (!parsed || typeof parsed !== 'object') continue
376
+ const edges = (parsed as { edges?: unknown }).edges
377
+ if (!Array.isArray(edges) || edges.length === 0) continue
378
+ const cleaned = edges
379
+ .filter((edge): edge is Record<string, unknown> => Boolean(edge) && typeof edge === 'object')
380
+ .map((edge) => ({
381
+ toolName: String(edge.toolName ?? ''),
382
+ schemaHash: String(edge.schemaHash ?? ''),
383
+ argSource: Array.isArray(edge.argSource) ? edge.argSource as EdgArgSource[] : [],
384
+ guard: (edge.guard && typeof edge.guard === 'object' ? edge.guard : { kind: 'regex', expr: '' }) as EdgGuard,
385
+ destructive: edge.destructive === true,
386
+ dependsOn: Array.isArray(edge.dependsOn) ? edge.dependsOn.map((item) => String(item)) : undefined,
387
+ }))
388
+ .filter((edge) => edge.toolName !== '')
389
+ if (cleaned.length === 0) continue
390
+ return { edges: cleaned, authorText: raw }
391
+ }
392
+ return null
393
+ }
394
+
395
+ /* ------------------------------------------------------------------ *
396
+ * 预置模板库(可选来源)
397
+ * ------------------------------------------------------------------ */
398
+
399
+ export interface EdgTemplate {
400
+ id: string
401
+ description: string
402
+ edges: Array<Partial<EdgEdge> & { id?: string }>
403
+ }
404
+
405
+ export interface TemplateLibrary {
406
+ list(): EdgTemplate[]
407
+ find(id: string): EdgTemplate | null
408
+ /** 按工具名挑模板(供作者步骤参考)。 */
409
+ forTool(toolName: string): EdgTemplate[]
410
+ }
411
+
412
+ export function createTemplateLibrary(templates: EdgTemplate[] = []): TemplateLibrary {
413
+ const items = (Array.isArray(templates) ? templates : []).filter((item) => item && typeof item === 'object' && item.id)
414
+ return Object.freeze({
415
+ list: () => items.map((item) => ({ ...item, edges: item.edges.map((edge) => ({ ...edge })) })),
416
+ find: (id: string) => {
417
+ const found = items.find((item) => item.id === String(id))
418
+ return found ? { ...found, edges: found.edges.map((edge) => ({ ...edge })) } : null
419
+ },
420
+ forTool: (toolName: string) => items
421
+ .filter((item) => item.edges.some((edge) => String(edge.toolName ?? '') === String(toolName)))
422
+ .map((item) => ({ ...item, edges: item.edges.map((edge) => ({ ...edge })) })),
423
+ })
424
+ }
package/worker/gdr.ts ADDED
@@ -0,0 +1,233 @@
1
+ // SPDX-License-Identifier: AGPL-3.0-or-later
2
+ // Copyright (c) 2026 dsh-Master contributors — see LICENSE-CORE.
3
+
4
+ /**
5
+ * 3.3.0 · B2 `gdr`(守卫决策复用,设计 §7)。
6
+ *
7
+ * 「守卫」二字的全部含义就是**匹配顺序**,任一环不满足即 miss、回落慢模型:
8
+ *
9
+ * ① 工具名 → ② schemaHash → ③ 依赖边完成 → ④ 守卫表达式 → ⑤ 参数可确定性提取
10
+ *
11
+ * 只有五步全过才算 `hit`。miss 的原因必须**可归因**(`schema-drift` / `guard-miss` /
12
+ * `arg-unextractable` / `dependency-incomplete` / `tool-mismatch`),
13
+ * 命中率与未命中分布进报表,用于 RSI 改进(B4)。
14
+ *
15
+ * 安全取向:任何一步异常/歧义都判 miss —— 宁可回落慢模型,也不误替换。
16
+ */
17
+
18
+ import type { Edg, EdgEdge, GuardVerdict } from './cost-spi.ts'
19
+ import { evaluateGuard, resolveEdgeArgs } from './edg.ts'
20
+
21
+ export type GdrStage = 'toolName' | 'schemaHash' | 'dependencies' | 'guard' | 'args'
22
+
23
+ export type GdrMissReason =
24
+ | 'no-edge'
25
+ | 'tool-mismatch'
26
+ | 'schema-drift'
27
+ | 'dependency-incomplete'
28
+ | 'guard-miss'
29
+ | 'arg-unextractable'
30
+
31
+ export interface GdrMatch extends GuardVerdict {
32
+ hit: boolean
33
+ edgeId?: string
34
+ reason?: GdrMissReason
35
+ /** 在第几环失败(命中时为 null)。 */
36
+ stage: GdrStage | null
37
+ receiptDigest: string
38
+ /** 命中时:确定性提取出的参数(可直接交给原生派发器)。 */
39
+ args?: Record<string, unknown>
40
+ wouldSaveTokens?: number
41
+ at: number
42
+ }
43
+
44
+ export interface GdrMatchInput {
45
+ receipt: { toolName: string; text: string; digest: string }
46
+ graph: Edg
47
+ /** 本任务已完成的边 id(依赖检查用)。 */
48
+ completed?: Set<string> | string[]
49
+ /** 当前工具 schema 哈希(缺省用边自身的 schemaHash ⇒ 视为一致)。 */
50
+ schemaHashOf?: (toolName: string) => string | undefined
51
+ /** 命中后的收益估算(供 shadow 对照;不给则不计)。 */
52
+ estimateSavedTokens?: (edge: EdgEdge, args: Record<string, unknown>) => number
53
+ }
54
+
55
+ export interface GdrStats {
56
+ matches: number
57
+ hits: number
58
+ hitRate: number
59
+ byReason: Record<GdrMissReason, number>
60
+ byTool: Record<string, { matches: number; hits: number }>
61
+ byStage: Record<GdrStage, number>
62
+ lastAt: number | null
63
+ }
64
+
65
+ const EMPTY_REASONS = (): Record<GdrMissReason, number> => ({
66
+ 'no-edge': 0,
67
+ 'tool-mismatch': 0,
68
+ 'schema-drift': 0,
69
+ 'dependency-incomplete': 0,
70
+ 'guard-miss': 0,
71
+ 'arg-unextractable': 0,
72
+ })
73
+
74
+ const EMPTY_STAGES = (): Record<GdrStage, number> => ({
75
+ toolName: 0,
76
+ schemaHash: 0,
77
+ dependencies: 0,
78
+ guard: 0,
79
+ args: 0,
80
+ })
81
+
82
+ export interface Gdr {
83
+ match(input: GdrMatchInput): GdrMatch
84
+ stats(): GdrStats
85
+ /** 报表用:命中率 + miss 分布 + 每个工具表现(RSI 的输入)。 */
86
+ report(): { stats: GdrStats; suggestions: string[] }
87
+ reset(): void
88
+ }
89
+
90
+ export function createGdr(options: { now?: () => number } = {}): Gdr {
91
+ const now = typeof options.now === 'function' ? options.now : () => Date.now()
92
+ let matches = 0
93
+ let hits = 0
94
+ let lastAt: number | null = null
95
+ const byReason = EMPTY_REASONS()
96
+ const byStage = EMPTY_STAGES()
97
+ const byTool = new Map<string, { matches: number; hits: number }>()
98
+
99
+ function toolRow(toolName: string) {
100
+ if (!byTool.has(toolName)) byTool.set(toolName, { matches: 0, hits: 0 })
101
+ return byTool.get(toolName)!
102
+ }
103
+
104
+ function miss(reason: GdrMissReason, stage: GdrStage, receiptDigest: string, toolName: string): GdrMatch {
105
+ matches += 1
106
+ byReason[reason] += 1
107
+ byStage[stage] += 1
108
+ const row = toolRow(toolName)
109
+ row.matches += 1
110
+ lastAt = now()
111
+ return { hit: false, reason, stage, receiptDigest, at: lastAt }
112
+ }
113
+
114
+ /**
115
+ * 五步匹配。**顺序不可调换**:先便宜的后昂贵的,且 schema 漂移必须早于守卫判定——
116
+ * 否则旧图可能在新 schema 上"命中",那是最危险的误替换。
117
+ */
118
+ function match(input: GdrMatchInput): GdrMatch {
119
+ const receipt = input?.receipt ?? { toolName: '', text: '', digest: '' }
120
+ const graph = input?.graph
121
+ const toolName = String(receipt.toolName ?? '')
122
+ const digest = String(receipt.digest ?? '')
123
+ const edges = Array.isArray(graph?.edges) ? graph.edges : []
124
+
125
+ // ① 工具名
126
+ if (edges.length === 0) return miss('no-edge', 'toolName', digest, toolName)
127
+ const candidates = edges.filter((edge) => String(edge?.toolName ?? '') === toolName)
128
+ if (candidates.length === 0) return miss('tool-mismatch', 'toolName', digest, toolName)
129
+
130
+ const completed = input?.completed instanceof Set
131
+ ? input.completed
132
+ : new Set(Array.isArray(input?.completed) ? input.completed : [])
133
+
134
+ // 逐条候选走完 ②③④⑤;任一条全过即命中。
135
+ let firstMiss: { reason: GdrMissReason; stage: GdrStage } | null = null
136
+ for (const edge of candidates) {
137
+ // ② schemaHash:漂移即整图作废(不匹配就直接 miss,绝不继续)
138
+ const current = typeof input?.schemaHashOf === 'function' ? input.schemaHashOf(edge.toolName) : undefined
139
+ if (current !== undefined && current !== edge.schemaHash) {
140
+ if (!firstMiss) firstMiss = { reason: 'schema-drift', stage: 'schemaHash' }
141
+ continue
142
+ }
143
+ // ③ 依赖边完成
144
+ const deps = Array.isArray(edge.dependsOn) ? edge.dependsOn : []
145
+ if (deps.some((dep) => !completed.has(String(dep)))) {
146
+ if (!firstMiss) firstMiss = { reason: 'dependency-incomplete', stage: 'dependencies' }
147
+ continue
148
+ }
149
+ // ④ 守卫表达式
150
+ if (!evaluateGuard(edge.guard, String(receipt.text ?? ''))) {
151
+ if (!firstMiss) firstMiss = { reason: 'guard-miss', stage: 'guard' }
152
+ continue
153
+ }
154
+ // ⑤ 参数可确定性提取
155
+ const args = resolveEdgeArgs(edge, String(receipt.text ?? ''))
156
+ if (args === null) {
157
+ if (!firstMiss) firstMiss = { reason: 'arg-unextractable', stage: 'args' }
158
+ continue
159
+ }
160
+ // 五步全过 ⇒ 命中
161
+ matches += 1
162
+ hits += 1
163
+ const row = toolRow(toolName)
164
+ row.matches += 1
165
+ row.hits += 1
166
+ lastAt = now()
167
+ const wouldSaveTokens = typeof input?.estimateSavedTokens === 'function'
168
+ ? Math.max(0, Number(input.estimateSavedTokens(edge, args)) || 0)
169
+ : 0
170
+ return {
171
+ hit: true,
172
+ edgeId: edge.id,
173
+ stage: null,
174
+ receiptDigest: digest,
175
+ args,
176
+ wouldSaveTokens,
177
+ at: lastAt,
178
+ }
179
+ }
180
+
181
+ const reason = firstMiss ?? { reason: 'guard-miss' as GdrMissReason, stage: 'guard' as GdrStage }
182
+ return miss(reason.reason, reason.stage, digest, toolName)
183
+ }
184
+
185
+ function stats(): GdrStats {
186
+ return {
187
+ matches,
188
+ hits,
189
+ hitRate: matches > 0 ? hits / matches : 0,
190
+ byReason: { ...byReason },
191
+ byTool: Object.fromEntries([...byTool.entries()].map(([tool, row]) => [tool, { ...row }])),
192
+ byStage: { ...byStage },
193
+ lastAt,
194
+ }
195
+ }
196
+
197
+ /** 报表 + 可执行的改进建议(喂给 RSI:哪一环最该优化)。 */
198
+ function report(): { stats: GdrStats; suggestions: string[] } {
199
+ const current = stats()
200
+ const suggestions: string[] = []
201
+ if (current.matches === 0) suggestions.push('样本为空:先跑 shadow 累积回执再评估')
202
+ if (current.byReason['schema-drift'] > 0) {
203
+ suggestions.push(`schema-drift ${current.byReason['schema-drift']} 次:优先复算 schemaHash 并回收旧图`)
204
+ }
205
+ if (current.byReason['guard-miss'] > 0) {
206
+ suggestions.push(`guard-miss ${current.byReason['guard-miss']} 次:守卫表达式过严或回执形态变化`)
207
+ }
208
+ if (current.byReason['arg-unextractable'] > 0) {
209
+ suggestions.push(`arg-unextractable ${current.byReason['arg-unextractable']} 次:参数来源应改为 jsonpath/regex 可命中的字段`)
210
+ }
211
+ if (current.byReason['dependency-incomplete'] > 0) {
212
+ suggestions.push(`dependency-incomplete ${current.byReason['dependency-incomplete']} 次:依赖链过长,考虑拆分图`)
213
+ }
214
+ if (current.byReason['tool-mismatch'] > 0) {
215
+ suggestions.push(`tool-mismatch ${current.byReason['tool-mismatch']} 次:图里缺少该工具的边`)
216
+ }
217
+ if (current.matches >= 20 && current.hitRate < 0.5) {
218
+ suggestions.push(`命中率 ${(current.hitRate * 100).toFixed(1)}% 偏低:建议先扩充图或放宽守卫`)
219
+ }
220
+ return { stats: current, suggestions }
221
+ }
222
+
223
+ function reset(): void {
224
+ matches = 0
225
+ hits = 0
226
+ lastAt = null
227
+ Object.assign(byReason, EMPTY_REASONS())
228
+ Object.assign(byStage, EMPTY_STAGES())
229
+ byTool.clear()
230
+ }
231
+
232
+ return Object.freeze({ match, stats, report, reset })
233
+ }