@danceiny/gotry 0.0.1-rc.14 → 0.0.1-rc.16

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 (142) hide show
  1. package/README.md +26 -20
  2. package/README.zh-CN.md +27 -21
  3. package/bin/gotry-bootstrap.js +331 -3
  4. package/cordis.gotry-patch.yml +10 -0
  5. package/data/airline-airports.json +39 -0
  6. package/dist/capabilities/agent-reach-deep.js +1 -1
  7. package/dist/capabilities/agent-reach.js +1 -1
  8. package/dist/capabilities/anything.js +1 -1
  9. package/dist/capabilities/artifacts.js +217 -0
  10. package/dist/capabilities/effect.js +293 -0
  11. package/dist/capabilities/fact-log.js +41 -0
  12. package/dist/capabilities/flyai.js +1 -1
  13. package/dist/capabilities/hbcli.js +43 -1
  14. package/dist/capabilities/incident-log.js +1 -1
  15. package/dist/capabilities/opensky.js +1 -1
  16. package/dist/capabilities/resilience.js +90 -0
  17. package/dist/capabilities/session/action-cache.js +1 -1
  18. package/dist/capabilities/session/adapters/ctrip-flight.js +1 -1
  19. package/dist/capabilities/session/adapters/meituan-local.js +1 -1
  20. package/dist/capabilities/session/benchmark.js +2 -1
  21. package/dist/capabilities/session/extension-bridge.js +345 -0
  22. package/dist/capabilities/session/extension-channel.js +93 -0
  23. package/dist/capabilities/session/extract.js +1 -1
  24. package/dist/capabilities/session/health-watch.js +216 -0
  25. package/dist/capabilities/session/read-guard.js +1 -1
  26. package/dist/capabilities/session/transport.js +1 -1
  27. package/dist/capabilities/session/wizard.js +377 -0
  28. package/dist/capabilities/session-consent.js +1 -1
  29. package/dist/capabilities/session-login.js +77 -1
  30. package/dist/capabilities/session-search.js +69 -2
  31. package/dist/capabilities/weather.js +1 -1
  32. package/dist/scripts/action-cache-tests.js +1 -1
  33. package/dist/scripts/agent-reach-deep-tests.js +1 -1
  34. package/dist/scripts/agent-reach-tests.js +1 -1
  35. package/dist/scripts/agent-reach-wrapper-tests.js +1 -1
  36. package/dist/scripts/anything-tests.js +1 -1
  37. package/dist/scripts/async-collect.js +1 -1
  38. package/dist/scripts/booking-saga-tests.js +187 -0
  39. package/dist/scripts/bootstrap-tests.js +49 -3
  40. package/dist/scripts/build-changelog.js +279 -0
  41. package/dist/scripts/changelog-tests.js +165 -0
  42. package/dist/scripts/companion-tests.js +1 -1
  43. package/dist/scripts/diff-test.js +1 -1
  44. package/dist/scripts/effect-tests.js +474 -0
  45. package/dist/scripts/engine-run.js +1 -1
  46. package/dist/scripts/engine-tests.js +1 -1
  47. package/dist/scripts/extension-tests.js +468 -0
  48. package/dist/scripts/fact-gate-tests.js +318 -0
  49. package/dist/scripts/flyai-tests.js +1 -1
  50. package/dist/scripts/hbcli-e2e-tests.js +180 -0
  51. package/dist/scripts/hbcli-tests.js +1 -1
  52. package/dist/scripts/health-watch-cli.js +51 -0
  53. package/dist/scripts/i18n-tests.js +1 -1
  54. package/dist/scripts/incident-tests.js +1 -1
  55. package/dist/scripts/journey-tests.js +1 -1
  56. package/dist/scripts/ledger-tests.js +1 -1
  57. package/dist/scripts/ledger-workflow-crash.js +1 -1
  58. package/dist/scripts/memory-capture-tests.js +1 -1
  59. package/dist/scripts/memory-decay-tests.js +1 -1
  60. package/dist/scripts/memory-metrics.js +1 -1
  61. package/dist/scripts/memory-value-report.js +1 -1
  62. package/dist/scripts/nightly-evidence-tests.js +186 -0
  63. package/dist/scripts/nightly-evidence.js +289 -0
  64. package/dist/scripts/nudge-digest.js +1 -1
  65. package/dist/scripts/onboarding-tests.js +218 -0
  66. package/dist/scripts/opensky-check.js +1 -1
  67. package/dist/scripts/opensky-tests.js +1 -1
  68. package/dist/scripts/price-drift-tests.js +441 -0
  69. package/dist/scripts/price-drift-watch.js +491 -0
  70. package/dist/scripts/probe-poi-tests.js +1 -1
  71. package/dist/scripts/product-metrics.js +1 -1
  72. package/dist/scripts/publish-preverify.js +1 -1
  73. package/dist/scripts/realtime-pricing-tests.js +1 -1
  74. package/dist/scripts/replay-async.js +1 -1
  75. package/dist/scripts/replay-real.js +1 -1
  76. package/dist/scripts/replay.js +1 -1
  77. package/dist/scripts/session-attach-diagnose.js +1 -1
  78. package/dist/scripts/session-attach-poc.js +1 -1
  79. package/dist/scripts/session-benchmark.js +1 -1
  80. package/dist/scripts/session-extract-tests.js +1 -1
  81. package/dist/scripts/session-login.js +1 -1
  82. package/dist/scripts/session-tests.js +1 -1
  83. package/dist/scripts/skeleton-check.js +1 -1
  84. package/dist/scripts/skeleton-integration-test.js +1 -1
  85. package/dist/scripts/skills-contract-tests.js +1 -1
  86. package/dist/scripts/smoke.js +159 -1
  87. package/dist/scripts/state-cli-tests.js +1 -1
  88. package/dist/scripts/state-cli.js +1 -1
  89. package/dist/scripts/time-eval-tests.js +1 -1
  90. package/dist/scripts/travel-timeline-tests.js +1 -1
  91. package/dist/scripts/unified-tests.js +1 -1
  92. package/dist/scripts/weather-tests.js +1 -1
  93. package/dist/scripts/wizard-bootstrap.js +32 -0
  94. package/dist/scripts/z3-race-tests.js +1 -1
  95. package/dist/src/artifact-gate.js +310 -0
  96. package/dist/src/bookable-facts.js +349 -0
  97. package/dist/src/booking-saga.js +153 -0
  98. package/dist/src/bridge.js +1 -1
  99. package/dist/src/companions.js +1 -1
  100. package/dist/src/contracts.js +1 -1
  101. package/dist/src/dsh-llm.js +31 -7
  102. package/dist/src/engine.js +1 -1
  103. package/dist/src/i18n.js +1 -1
  104. package/dist/src/index.js +322 -46
  105. package/dist/src/journey.js +1 -1
  106. package/dist/src/loop.js +1 -1
  107. package/dist/src/memory-capture.js +1 -1
  108. package/dist/src/memory-decay.js +1 -1
  109. package/dist/src/memory-utility.js +1 -1
  110. package/dist/src/mock-llm.js +1 -1
  111. package/dist/src/model.js +1 -1
  112. package/dist/src/realtime-pricing.js +14 -7
  113. package/dist/src/slot-spec.js +1 -1
  114. package/dist/src/state-ledger.js +1 -1
  115. package/dist/src/time-anchor.js +1 -1
  116. package/dist/src/tool-packet.js +1 -1
  117. package/dist/src/travel-slots.js +1 -1
  118. package/dist/src/travel-timeline.js +1 -1
  119. package/dist/src/unified.js +1 -1
  120. package/dist/src/wish-pool.js +1 -1
  121. package/dist/src/z3-shared.js +1 -1
  122. package/extension/README.md +26 -0
  123. package/extension/background.js +160 -0
  124. package/extension/content-bridge.js +41 -0
  125. package/extension/content-main.js +62 -0
  126. package/extension/manifest.json +32 -0
  127. package/package.json +8 -2
  128. package/ts/capabilities/artifacts.ts +235 -0
  129. package/ts/capabilities/fact-log.ts +54 -0
  130. package/ts/capabilities/hbcli.ts +46 -0
  131. package/ts/capabilities/session/benchmark.ts +4 -1
  132. package/ts/capabilities/session/extension-bridge.ts +354 -0
  133. package/ts/capabilities/session/extension-channel.ts +92 -0
  134. package/ts/capabilities/session/health-watch.ts +257 -0
  135. package/ts/capabilities/session/wizard.ts +282 -0
  136. package/ts/capabilities/session-login.ts +53 -2
  137. package/ts/capabilities/session-search.ts +68 -3
  138. package/ts/src/artifact-gate.ts +365 -0
  139. package/ts/src/bookable-facts.ts +557 -0
  140. package/ts/src/booking-saga.ts +133 -0
  141. package/ts/src/dsh-llm.ts +40 -3
  142. package/ts/src/index.ts +230 -28
@@ -0,0 +1,133 @@
1
+ /**
2
+ * 预订 saga 状态机(booking_saga_fsm.v1)——issue #17 采纳三点的具名化落点。
3
+ *
4
+ * 立场(docs/booking-saga-fsm.md / ADR-17):不引入编排框架;预订 saga 的状态字母表与边表
5
+ * 就是账本 pending_writes(status CHECK:pending|confirmed|compensated)的显式化。
6
+ * 本模块是纯函数词汇层,零依赖、零行为、不接线任何 seam——M5 拍板 WriteGate 生产化时
7
+ * 以此为唯一状态推进词汇(具名化 = 状态与边不再散落在 SQL 字符串里)。
8
+ *
9
+ * 物理对账目标(state-ledger.ts,TS-4 基座):
10
+ * - propose = requestPendingWrite(∅ → pending,L2 只登记不执行;idem_key UNIQUE,重复提议 no-op)
11
+ * - confirm = confirmPendingWrite(pending → confirmed,L3 具名 seam 确认,携 receipt;无 receipt 则视为缺省空串)
12
+ * - compensate = compensatePendingWrite(pending|confirmed → compensated,吸收态,无出边)
13
+ * - 审计事件 kind:write.pending / write.confirmed / write.compensated
14
+ */
15
+
16
+ export const BOOKING_SAGA_SCHEMA = 'booking_saga_fsm.v1'
17
+
18
+ export type SagaStatus = 'pending' | 'confirmed' | 'compensated'
19
+ export type SagaOrigin = 'none' | SagaStatus
20
+ export type SagaTrigger = 'propose' | 'confirm' | 'compensate'
21
+ export type SagaLedgerEvent = 'write.pending' | 'write.confirmed' | 'write.compensated'
22
+
23
+ /** 状态字母表(与 pending_writes.status CHECK 约束逐字一致) */
24
+ export const SAGA_STATUSES = ['pending', 'confirmed', 'compensated'] as const satisfies readonly SagaStatus[]
25
+
26
+ export interface SagaEdge {
27
+ from: SagaOrigin
28
+ trigger: SagaTrigger
29
+ to: SagaStatus
30
+ event: SagaLedgerEvent
31
+ /** 边的守卫说明;物理执行在账本事务内(UNIQUE 守卫/CHECK),此处为语义面 */
32
+ guard: string
33
+ }
34
+
35
+ /**
36
+ * 边表(预订流程 FSM 的全部合法转移):
37
+ * ∅ --propose--> pending --confirm--> confirmed
38
+ * │ │
39
+ * └---(任何状态) --compensate--> compensated(吸收态,无出边)
40
+ */
41
+ export const SAGA_EDGES: ReadonlyArray<SagaEdge> = [
42
+ { from: 'none', trigger: 'propose', to: 'pending', event: 'write.pending', guard: 'L2 只登记不执行;idem_key UNIQUE(重复提议 no-op)' },
43
+ { from: 'pending', trigger: 'confirm', to: 'confirmed', event: 'write.confirmed', guard: 'L3 具名 seam 确认,必携 receipt;已确认不可再确认' },
44
+ { from: 'pending', trigger: 'compensate', to: 'compensated', event: 'write.compensated', guard: '外部写未发生,取消即终态(无补偿动作)' },
45
+ { from: 'confirmed', trigger: 'compensate', to: 'compensated', event: 'write.compensated', guard: '已发生副作用的 saga 补偿(退改),receipt 保留(COALESCE)' },
46
+ ]
47
+
48
+ export type SagaRejection =
49
+ | 'missing-subject'
50
+ | 'idem-exists'
51
+ | 'already-confirmed'
52
+ | 'absorbed-compensated'
53
+ | 'already-compensated'
54
+
55
+ /** 非边组合的拒绝理由(结构化,拒绝即不动——与账本 changes=0 同义) */
56
+ export const SAGA_REJECTIONS: ReadonlyArray<{ from: SagaOrigin; trigger: SagaTrigger; reason: SagaRejection }> = [
57
+ { from: 'none', trigger: 'confirm', reason: 'missing-subject' },
58
+ { from: 'none', trigger: 'compensate', reason: 'missing-subject' },
59
+ { from: 'pending', trigger: 'propose', reason: 'idem-exists' },
60
+ { from: 'confirmed', trigger: 'propose', reason: 'idem-exists' },
61
+ { from: 'confirmed', trigger: 'confirm', reason: 'already-confirmed' },
62
+ { from: 'compensated', trigger: 'propose', reason: 'idem-exists' },
63
+ { from: 'compensated', trigger: 'confirm', reason: 'absorbed-compensated' },
64
+ { from: 'compensated', trigger: 'compensate', reason: 'already-compensated' },
65
+ ]
66
+
67
+ export type SagaVerdict =
68
+ | { ok: true; from: SagaOrigin; trigger: SagaTrigger; to: SagaStatus; event: SagaLedgerEvent; guard: string; reason: null }
69
+ | { ok: false; from: SagaOrigin; trigger: SagaTrigger; to: null; event: null; guard: null; reason: SagaRejection }
70
+
71
+ /** 触发解析:唯一归宿或结构化拒绝(预订 seam 的状态推进只许走这张表的边) */
72
+ export function resolveSagaTrigger(origin: SagaOrigin, trigger: SagaTrigger): SagaVerdict {
73
+ const edge = SAGA_EDGES.find(e => e.from === origin && e.trigger === trigger)
74
+ if (edge) return { ok: true, from: origin, trigger, to: edge.to, event: edge.event, guard: edge.guard, reason: null }
75
+ const rejection = SAGA_REJECTIONS.find(e => e.from === origin && e.trigger === trigger)
76
+ return { ok: false, from: origin, trigger, to: null, event: null, guard: null, reason: rejection!.reason }
77
+ }
78
+
79
+ /** 触发器 → 账本事件 kind 的唯一映射(写入侧命名不再各处硬编码) */
80
+ export const SAGA_TRIGGER_EVENT: Record<SagaTrigger, SagaLedgerEvent> = {
81
+ propose: 'write.pending',
82
+ confirm: 'write.confirmed',
83
+ compensate: 'write.compensated',
84
+ }
85
+
86
+ /** 账本事件 kind → 触发器(读取/审计侧);非 saga 事件返回 null */
87
+ export function triggerOfEventKind(kind: string): SagaTrigger | null {
88
+ if (kind === 'write.pending') return 'propose'
89
+ if (kind === 'write.confirmed') return 'confirm'
90
+ if (kind === 'write.compensated') return 'compensate'
91
+ return null
92
+ }
93
+
94
+ export interface SagaAuditEvent {
95
+ seq?: number
96
+ kind: string
97
+ /** write.confirmed 携带的外部回执(payload.receipt) */
98
+ receipt?: string | null
99
+ }
100
+
101
+ /**
102
+ * 审计链校验(单 idem_key 的 write.* 事件序列):必须恰好走出边表的一条合法路径。
103
+ * - 链以 write.pending 开头;
104
+ * - confirm/compensate 至多各一次;
105
+ * - compensated 之后不允许任何 saga 事件(吸收态);
106
+ * - write.confirmed 的 receipt 为空(缺省/空串)即违例——L3 具名确认必携外部回执;
107
+ * - 非 write.* 事件(其他域审计)穿插不计。
108
+ * 返回违例清单(空数组 = 链合法)。
109
+ */
110
+ export function sagaTraceViolations(events: ReadonlyArray<SagaAuditEvent>): string[] {
111
+ const violations: string[] = []
112
+ let stage: SagaOrigin = 'none'
113
+ for (const ev of events) {
114
+ const trigger = triggerOfEventKind(ev.kind)
115
+ if (!trigger) continue
116
+ const verdict = resolveSagaTrigger(stage, trigger)
117
+ const at = `seq=${ev.seq ?? '?'} ${ev.kind}`
118
+ if (!verdict.ok) {
119
+ violations.push(`${at}: 非法(${stage} --${trigger}--> 拒绝 ${verdict.reason})`)
120
+ continue
121
+ }
122
+ stage = verdict.to
123
+ if (trigger === 'confirm' && (ev.receipt === undefined || ev.receipt === null || ev.receipt === '')) {
124
+ violations.push(`${at}: receipt 为空(L3 具名确认必携外部回执)`)
125
+ }
126
+ }
127
+ return violations
128
+ }
129
+
130
+ /** 完整合法路径集合(文档/测试共用):∅→pending→confirmed→compensated 与其前缀 */
131
+ export function sagaLegalPaths(): ReadonlyArray<ReadonlyArray<SagaTrigger>> {
132
+ return [['propose'], ['propose', 'compensate'], ['propose', 'confirm'], ['propose', 'confirm', 'compensate']]
133
+ }
package/ts/src/dsh-llm.ts CHANGED
@@ -18,7 +18,25 @@ import { buildSlotSystem, flagExpiredSlots, normalizeExtraction, type TravelSlot
18
18
  const model = () => process.env['LLM_MODEL'] ?? process.env['DEEPSEEK_MODEL'] ?? 'MiniMax-M2'
19
19
  const base = () => (process.env['LLM_BASE_URL'] ?? process.env['DEEPSEEK_BASE_URL'] ?? 'https://api.minimax.io/v1').replace(/\/$/, '')
20
20
 
21
- async function chat(messages: Array<{ role: 'system' | 'user' | 'assistant'; content: string }>, json: boolean): Promise<string> {
21
+ /**
22
+ * Token 用量累计器(OpenAI 兼容 usage 映射)。nightly real-LLM 成本证据(Issue #22
23
+ * `gotry_m3_nightly_run_v1.cost_usd`)的唯一测量来源——成本不允许凭空断言,只允许
24
+ * 这里实测出的 tokens × 封存价表换算;responsesMissingUsage > 0 时成本不可证,调用方必须 fail-closed。
25
+ */
26
+ export interface LlmUsageTracker {
27
+ calls: number
28
+ inputTokens: number
29
+ outputTokens: number
30
+ inputCacheHitTokens: number
31
+ inputCacheMissTokens: number
32
+ responsesMissingUsage: number
33
+ }
34
+
35
+ function emptyUsage(): LlmUsageTracker {
36
+ return { calls: 0, inputTokens: 0, outputTokens: 0, inputCacheHitTokens: 0, inputCacheMissTokens: 0, responsesMissingUsage: 0 }
37
+ }
38
+
39
+ async function chat(messages: Array<{ role: 'system' | 'user' | 'assistant'; content: string }>, json: boolean, usage?: LlmUsageTracker): Promise<string> {
22
40
  const key = process.env['LLM_API_KEY'] ?? process.env['DEEPSEEK_API_KEY']
23
41
  if (!key) throw new Error('LLM_API_KEY 未设置(兼容 DEEPSEEK_API_KEY 别名)——真 LLM 路径不可用,请回退 mock(ADR-8)')
24
42
  const res = await fetch(`${base()}/chat/completions`, {
@@ -32,7 +50,20 @@ async function chat(messages: Array<{ role: 'system' | 'user' | 'assistant'; con
32
50
  }),
33
51
  })
34
52
  if (!res.ok) throw new Error(`llm ${res.status}: ${(await res.text()).slice(0, 300)}`)
35
- const data = await res.json() as { choices: Array<{ message: { content: string } }> }
53
+ const data = await res.json() as { choices: Array<{ message: { content: string } }>; usage?: { prompt_tokens?: number; completion_tokens?: number; prompt_cache_hit_tokens?: number; prompt_cache_miss_tokens?: number } }
54
+ if (usage) {
55
+ usage.calls += 1
56
+ const u = data.usage
57
+ if (u && Number.isFinite(u.prompt_tokens) && Number.isFinite(u.completion_tokens)) {
58
+ usage.inputTokens += u.prompt_tokens ?? 0
59
+ usage.outputTokens += u.completion_tokens ?? 0
60
+ usage.inputCacheHitTokens += u.prompt_cache_hit_tokens ?? 0
61
+ // 无缓存明细的 provider:全部输入按 miss 记(随后按 peak miss 价换算,只高不低)
62
+ usage.inputCacheMissTokens += u.prompt_cache_miss_tokens ?? (u.prompt_tokens ?? 0)
63
+ } else {
64
+ usage.responsesMissingUsage += 1
65
+ }
66
+ }
36
67
  const raw = data.choices[0]?.message?.content ?? ''
37
68
  // 推理模型(MiniMax-M2 等):剥 <think> 块,只留正文;未闭合时留全文由上层容错
38
69
  return raw.replace(/<think>[\s\S]*?<\/think>/g, '').trim() || raw
@@ -62,8 +93,9 @@ const SKELETON_SYSTEM = `你是行程骨架抽取器。从对话中抽取行程
62
93
  规则:每个跨城移动一段;锚点只放用户明说或必然的(如"当天到"→arriveByMin 23:59=1439);时刻用当日分钟。
63
94
  scenario 判定:「洱海/大理/千岛湖/太湖+选目的地」→erhai(候选集);「普吉/workation/远程办公+多城链」→workation(五段链);「云南/大理丽江」→yunnan;不确定→generic。只输出 JSON。`
64
95
 
65
- export function createOpenAICompatLlm(flightPackPath?: string, clock: () => Date = () => new Date()): LlmPort {
96
+ export function createOpenAICompatLlm(flightPackPath?: string, clock: () => Date = () => new Date()): LlmPort & { usage: LlmUsageTracker } {
66
97
  const pack = flightPackPath
98
+ const usage = emptyUsage()
67
99
  const historyText = (h: Turn[]) => h.map(t => `${t.role === 'user' ? '用户' : '助手'}: ${t.text}`).join('\n')
68
100
  // 时间锚点卡:每条抽取链路都带上「今天」——legacy 路径此前无时间注入,
69
101
  // 过期/相对日期语义全靠它(算术在 time-anchor 层,LLM 只查卡)。
@@ -73,6 +105,7 @@ export function createOpenAICompatLlm(flightPackPath?: string, clock: () => Date
73
105
  const out = await chat(
74
106
  [{ role: 'system', content: FACTS_SYSTEM }, { role: 'user', content: `${anchorContext()}\n\n${historyText(history)}` }],
75
107
  true,
108
+ usage,
76
109
  )
77
110
  const obj = parseJsonBlock(out)
78
111
  if (!obj) return { assumptions: [] }
@@ -87,6 +120,7 @@ export function createOpenAICompatLlm(flightPackPath?: string, clock: () => Date
87
120
  const out = await chat(
88
121
  [{ role: 'system', content: SKELETON_SYSTEM }, { role: 'user', content: `${context}\n\n${historyText(history)}` }],
89
122
  true,
123
+ usage,
90
124
  )
91
125
  const skeleton = parseJsonBlock(out)
92
126
  if (!skeleton || !Array.isArray(skeleton['segments']) || (skeleton['segments'] as unknown[]).length === 0) return null
@@ -136,6 +170,7 @@ export function createOpenAICompatLlm(flightPackPath?: string, clock: () => Date
136
170
  const out = await chat(
137
171
  [{ role: 'system', content: buildSlotSystem(anchor) }, { role: 'user', content: historyText(history) }],
138
172
  true,
173
+ usage,
139
174
  )
140
175
  const obj = parseJsonBlock(out)
141
176
  if (!obj) return null
@@ -148,8 +183,10 @@ export function createOpenAICompatLlm(flightPackPath?: string, clock: () => Date
148
183
  [{ role: 'system', content: '把旅行规划的追问润色得更自然,保留全部信息(含为什么问),一两句话,不要加表情。' },
149
184
  { role: 'user', content: `【${q.key}】${q.text}(为什么问:${q.why})` }],
150
185
  false,
186
+ usage,
151
187
  )
152
188
  return out.trim() || `【${q.key}】${q.text}`
153
189
  },
190
+ usage,
154
191
  }
155
192
  }
package/ts/src/index.ts CHANGED
@@ -22,7 +22,6 @@ import { ensureStateDir, recordLatency } from './bridge.ts'
22
22
  import { segmentsFromCandidate, solveChoiceSegment } from './unified.ts'
23
23
  import { checkConnectivity } from '../scripts/skeleton-check.ts'
24
24
  import { parseCandidate, parseRequest } from './model.ts'
25
- import { searchHotels as hbcliSearchHotels } from '../capabilities/hbcli.ts'
26
25
  import { installProcessGuards, guardToolExecute } from '../capabilities/incident-log.ts'
27
26
  import { interpretArgs, type GotryObservation } from './tool-packet.ts'
28
27
  import { projectUtility } from './memory-utility.ts'
@@ -31,15 +30,32 @@ import { resolveTimelineDate } from './travel-timeline.ts'
31
30
  import { ensureLedger, readCompanionsWithFallback, readMotivationWithFallback, readTripsWithFallback, readWishPoolWithFallback } from './state-ledger.ts'
32
31
  import { buildTimeAnchor } from './time-anchor.ts'
33
32
  import { resolveSlotDate } from './slot-spec.ts'
34
- import { geocodePlace, getForecast, getClimate, wmoLabel } from '../capabilities/weather.ts'
35
- import { verifyFlight } from '../capabilities/opensky.ts'
33
+ import { wmoLabel } from '../capabilities/weather.ts'
36
34
  import { anythingSearch } from '../capabilities/anything.ts'
37
35
  import { readUrl, reach, reachStatus } from '../capabilities/agent-reach.ts'
38
36
  import { videoSubtitle, githubSearch } from '../capabilities/agent-reach-deep.ts'
39
- import { flyaiSearch } from '../capabilities/flyai.ts'
40
- import { sessionFlightSearch } from '../capabilities/session-search.ts'
41
37
  import { sessionLogin } from '../capabilities/session-login.ts'
42
38
  import { createConsentGate, approvalFromContext } from '../capabilities/session-consent.ts'
39
+ import { listArtifacts, readArtifact } from '../capabilities/artifacts.ts'
40
+ import { interpretEffect, declinedObservation } from '../capabilities/effect.ts'
41
+ import { appendFacts, loadFactRegistry } from '../capabilities/fact-log.ts'
42
+ import { factsFromFlyai, factsFromSession } from './bookable-facts.ts'
43
+ import { gateArtifact, type AirlineAirportMap } from './artifact-gate.ts'
44
+
45
+ /** 航司→机场映射表(issue #46 冲突检测面;data/airline-airports.json,as_of 快照) */
46
+ let airlineAirportMapCache: AirlineAirportMap | null = null
47
+ async function loadAirlineAirportMap(): Promise<AirlineAirportMap | null> {
48
+ if (airlineAirportMapCache) return airlineAirportMapCache
49
+ try {
50
+ const { readFile } = await import('node:fs/promises')
51
+ airlineAirportMapCache = JSON.parse(
52
+ await readFile(join(import.meta.dirname, '..', '..', 'data', 'airline-airports.json'), 'utf-8'),
53
+ ) as AirlineAirportMap
54
+ return airlineAirportMapCache
55
+ } catch {
56
+ return null // 映射缺失不阻塞检索主路径;闸工具侧另行显式报错
57
+ }
58
+ }
43
59
 
44
60
  export const name = 'gotry-tools'
45
61
  export const inject = ['tools', 'systemPrompt']
@@ -493,10 +509,14 @@ export function apply(ctx: Context, config: Config): void {
493
509
  if (r.raw !== r.date) dateNotes.push(`slot-resolved: ${r.raw} → ${r.date}`)
494
510
  return r.date
495
511
  }
496
- const resp = await hbcliSearchHotels(
497
- { destination: q.destination, checkIn: resolveDate(q.checkIn), checkOut: resolveDate(q.checkOut), adults: q.adults },
498
- { hbcliBin: config.hbcliBin, timeoutMs: config.timeoutMs, fallbackPath },
499
- )
512
+ // 效应解译层(ADR-18):渠道选择/韧性(重试/熔断)在解译器,result 原样透传——
513
+ // 断路拒绝时返回平铺失败面(不发起查询,不伪装成 miss)
514
+ const itp = await interpretEffect({
515
+ effect: 'HBCLI_HOTEL_SEARCH',
516
+ params: { destination: q.destination, checkIn: resolveDate(q.checkIn), checkOut: resolveDate(q.checkOut), adults: q.adults, hbcliBin: config.hbcliBin, timeoutMs: config.timeoutMs, fallbackPath },
517
+ })
518
+ if (!itp.result) return declinedObservation('HBCLI_HOTEL_SEARCH', itp.trace)
519
+ const resp = itp.result
500
520
  const dir = await ensureStateDir(config.stateRoot)
501
521
  const isLive = resp.via === 'hbcli-realtime'
502
522
  const evidence = isLive ? resp.evidence : '[静态包:估算]'
@@ -583,7 +603,9 @@ export function apply(ctx: Context, config: Config): void {
583
603
  let placeLabel = q.place ?? `${q.lat},${q.lng}`
584
604
  if (lat === undefined || lng === undefined) {
585
605
  if (!q.place) throw new Error('gotry_weather_check requires place name or lat/lng')
586
- const geo = await geocodePlace(q.place)
606
+ const geoItp = await interpretEffect({ effect: 'WEATHER_GEOCODE', params: { name: q.place } })
607
+ if (!geoItp.result) return declinedObservation('WEATHER_GEOCODE', geoItp.trace)
608
+ const geo = geoItp.result
587
609
  if (!geo.ok || geo.results.length === 0) {
588
610
  return JSON.parse(JSON.stringify({ ok: false, summary: `地点「${q.place}」地理编码失败:${geo.error ?? '无结果'}`, evidence: geo.evidence })) as Record<string, never>
589
611
  }
@@ -592,9 +614,11 @@ export function apply(ctx: Context, config: Config): void {
592
614
  placeLabel = `${hit.name}(${hit.admin1 ?? hit.country ?? ''})`
593
615
  }
594
616
  const isClimate = q.mode === 'climate' || (q.month !== undefined && q.mode !== 'forecast')
595
- const r = isClimate
596
- ? await getClimate({ latitude: lat, longitude: lng }, q.month ?? new Date().getMonth() + 1)
597
- : await getForecast({ latitude: lat, longitude: lng }, { days: q.days })
617
+ const wxItp = isClimate
618
+ ? await interpretEffect({ effect: 'WEATHER_CLIMATE', params: { latitude: lat, longitude: lng, month: q.month ?? new Date().getMonth() + 1 } })
619
+ : await interpretEffect({ effect: 'WEATHER_FORECAST', params: { latitude: lat, longitude: lng, days: q.days } })
620
+ if (!wxItp.result) return declinedObservation(isClimate ? 'WEATHER_CLIMATE' : 'WEATHER_FORECAST', wxItp.trace)
621
+ const r = wxItp.result
598
622
  const dir = await ensureStateDir(config.stateRoot)
599
623
  await recordLatency(join(dir, 'bridge-latency.jsonl'), Date.now() - started, `weather:${r.via}`).catch(() => {})
600
624
  const dailyLines = (r.daily ?? []).slice(0, 7).map(d =>
@@ -649,7 +673,9 @@ export function apply(ctx: Context, config: Config): void {
649
673
  if (!q.callsign) {
650
674
  return JSON.parse(JSON.stringify({ verdict: 'unavailable', evidence: '[校验不可用:无 callsign]', summary: 'callsign 必填' })) as Record<string, never>
651
675
  }
652
- const r = await verifyFlight({ callsign: q.callsign, airport: q.airport, timeoutMs: q.timeoutMs })
676
+ const itp = await interpretEffect({ effect: 'OPENSKY_FLIGHT_VERIFY', params: { callsign: q.callsign, airport: q.airport, timeoutMs: q.timeoutMs } })
677
+ if (!itp.result) return declinedObservation('OPENSKY_FLIGHT_VERIFY', itp.trace)
678
+ const r = itp.result
653
679
  const dir = await ensureStateDir(config.stateRoot)
654
680
  await recordLatency(join(dir, 'bridge-latency.jsonl'), Date.now() - started, `flight_verify:${r.via}`).catch(() => {})
655
681
  const summary = r.verdict === 'observed'
@@ -701,7 +727,9 @@ export function apply(ctx: Context, config: Config): void {
701
727
  return { ok: false, summary: 'checkOut 需 YYYY-MM-DD(与 checkIn 成对)' } as const
702
728
  }
703
729
  }
704
- const r = await flyaiSearch({ kind: 'hotel', destName: dest, checkInDate: q.checkIn, checkOutDate: q.checkOut, keyWords: q.keyWords })
730
+ const itp = await interpretEffect({ effect: 'FLYAI_SEARCH', params: { kind: 'hotel', destName: dest, checkInDate: q.checkIn, checkOutDate: q.checkOut, keyWords: q.keyWords } })
731
+ if (!itp.result) return declinedObservation('FLYAI_SEARCH', itp.trace)
732
+ const r = itp.result
705
733
  const top = (r.hotels ?? []).slice(0, 8).map(o => `${o.name}${o.star ? `(${o.star})` : ''} ${o.priceRaw ?? '价待询'}${o.poi ? ` · ${o.poi}` : ''}`)
706
734
  const summary = r.verdict === 'hit'
707
735
  ? `${dest} 酒店(飞猪官方只读)前 ${top.length} 家(价格多为打码,真实价以 jumpUrl 为准):\n${top.join('\n')}\n${r.evidence}`
@@ -724,7 +752,13 @@ export function apply(ctx: Context, config: Config): void {
724
752
  + `多为用户时间表达未带年份所致——向用户确认年份(或按未来最近的同月日修正)后再查。`,
725
753
  })) as Record<string, never>
726
754
  }
727
- const r = await flyaiSearch({ kind, origin: q.from, destination: q.to, depDate: q.date })
755
+ const itp = await interpretEffect({ effect: 'FLYAI_SEARCH', params: { kind, origin: q.from, destination: q.to, depDate: q.date } })
756
+ if (!itp.result) return declinedObservation('FLYAI_SEARCH', itp.trace)
757
+ const r = itp.result
758
+ // issue #46 事实落账(ADR-19):exact-date 检索结论(hit 正事实 / miss 负事实)追加进
759
+ // bookable-facts 侧车——产物事实闸(gotry_fact_gate)的唯一事实源;落盘失败不阻塞检索
760
+ const avMap = await loadAirlineAirportMap()
761
+ await appendFacts(config.stateRoot ?? '.', factsFromFlyai({ kind, origin: q.from, destination: q.to, date: q.date }, r, new Date().toISOString(), avMap?.city_alias))
728
762
  const top = (r.options ?? []).slice(0, 8).map(o => `${o.no} ${o.name} ${o.depDateTime.slice(11, 16)}→${o.arrDateTime.slice(11, 16)} ¥${o.price}`)
729
763
  // issue #24:miss(上游正常返回 0 条)与 error(限流/网络)分开陈述,不再混写「无结果或失败」
730
764
  // (过去日期已被上方预校验拦下,此处不会出现过期查询)
@@ -749,9 +783,10 @@ export function apply(ctx: Context, config: Config): void {
749
783
  name: 'gotry_session_login',
750
784
  description:
751
785
  'Productized login bootstrap for the account session channel (call this when gotry_session_search returns needs-login — the user never needs a terminal). ' + 'AUTO-DETECTION FIRST: it reads ticket-cookie NAMES before anything else — if the user already logged in (on the external site) it confirms instantly WITHOUT opening any page. '
752
- + 'OPENS the site login entry in the USER\'S OWN Chrome and waits for the user to finish logging in on the external site. '
786
+ + 'OPENS the site login entry in the USER\'S OWN Chrome (foreground tab, left open for the user) and waits for the user to finish logging in on the external site. '
753
787
  + 'GoTry NEVER collects, stores, or transmits credentials: no passwords, no SMS codes, no cookie values — it only checks the boolean fact "already logged in" (reads cookie NAMES only, zero values). '
754
- + 'verdict logged-in (tickets detected) | pending (login tab opened, user not done yet — offer to re-check later) | needs-attach (one-time Chrome remote-debugging switch instructions). '
788
+ + 'Transport: the GoTry Session Bridge browser extension (one-time install, ZERO Chrome system dialogs). '
789
+ + 'verdict logged-in (tickets detected) | pending (login tab opened, user not done yet — offer to re-check later) | needs-extension (one-time extension install: npx gotry setup, then chrome://extensions → load unpacked ~/.gotry/extension). '
755
790
  + 'Evidence [会话:<site>-login@ts].',
756
791
  parameters: {
757
792
  query: { type: 'json', required: true, description: '可空对象 {}: { waitSeconds?: number }(等待用户完成登录的上限秒数,默认 90,至多 300)' },
@@ -764,9 +799,11 @@ export function apply(ctx: Context, config: Config): void {
764
799
  ? `${r.site} 登录完成确认(票据 cookie 名已检出,只读名字)。说明:登录是在携程官网、用你自己的浏览器完成的——gotry 全程未接触任何密码/验证码/cookie 值。现在可以继续会话检索了。${r.evidence}`
765
800
  : r.verdict === 'pending'
766
801
  ? `登录入口已在你的 Chrome 打开;请在弹出的标签页里正常登录携程(登录由你在官网完成,不属于 gotry)。完成后说一声"继续",我再确认。gotry 只检查"是否已登录",永不收集你的账号信息。${r.evidence}`
767
- : r.verdict === 'needs-attach'
768
- ? `需要一次性开启你 Chrome 的远程调试开关:在你的 Chrome 地址栏打开 chrome://inspect/#remote-debugging 并打开开关,然后说一声"重试"。${r.evidence}`
769
- : `登录引导未完成:${r.error ?? '未知原因'} ${r.evidence}`
802
+ : r.verdict === 'needs-extension'
803
+ ? `需要一次性安装 GoTry Session Bridge 浏览器扩展(约 30 秒,装完零弹窗):跑 npx gotry setup 拿安装指引,在 chrome://extensions 开发者模式「加载已解压的扩展程序」指向 ~/.gotry/extension,装好后说一声"重试"。${r.evidence}`
804
+ : r.verdict === 'needs-attach'
805
+ ? `cdp 车道需要一次性开启你 Chrome 的远程调试开关:在你的 Chrome 地址栏打开 chrome://inspect/#remote-debugging 并打开开关,然后说一声"重试"(默认走扩展车道,无需此步)。${r.evidence}`
806
+ : `登录引导未完成:${r.error ?? '未知原因'} ${r.evidence}`
770
807
  return JSON.parse(JSON.stringify({ ...r, summary })) as Record<string, never>
771
808
  },
772
809
  presentCall: _args => ({ card: 'generic', title: '登录携程(用你自己的浏览器,不在 gotry 输入)', kind: 'fetch', rawInput: {} }),
@@ -782,9 +819,10 @@ export function apply(ctx: Context, config: Config): void {
782
819
  description:
783
820
  'Search on the USER\'S OWN logged-in browser session (Ctrip flights today; the account channel, not an anonymous instance). '
784
821
  + 'Consent gate: the FIRST call in a session asks the user via the runtime approval card; once granted it holds for the session, a refusal revokes it for the session (no repeat prompting). '
785
- + 'ReadGuard = physically read-only (write requests aborted at network layer; agent NEVER touches credentials/captcha; on captcha it stops and returns challenged). '
822
+ + 'Transport: GoTry Session Bridge browser extension (one-time install) — the agent side never talks to Chrome debugging, ZERO system dialogs; read-only by construction (the extension never issues requests; it only passively forwards the site\'s own search responses; agent NEVER touches credentials/captcha; on captcha it stops and returns challenged). '
786
823
  + 'Currently ctrip-flight: sniffs the site search API for structured options. Evidence [会话:ctrip-flight@ts]. '
787
- + 'verdict needs-login = call gotry_session_login (product tool: opens the Ctrip login entry in the user\'s own Chrome and waits for them to log in on the external site — no terminal, no credentials through GoTry); needs-attach = one-time Chrome remote-debugging switch. '
824
+ + 'verdict needs-login = call gotry_session_login (opens the Ctrip login entry in the user\'s own foreground tab — no terminal, no credentials through GoTry); '
825
+ + 'needs-extension = one-time browser-extension install (npx gotry setup for instructions) — the DEFAULT transport; cdp (chrome://inspect remote debugging) is a diagnostic fallback only via GOTRY_SESSION_TRANSPORT=cdp. '
788
826
  + 'Rate-limited (≥30s between same-site calls; a challenged/timeout verdict means STOP — never retry, fall back to other tools).',
789
827
  parameters: {
790
828
  query: { type: 'json', required: true, description: '{ from: "上海", to: "丽江", date: "2026-10-01" }' },
@@ -795,11 +833,21 @@ export function apply(ctx: Context, config: Config): void {
795
833
  if (!q.from || !q.to || !q.date) {
796
834
  return { ok: false, summary: '需要 from/to(中文城市名,词表内)与 date(YYYY-MM-DD)' } as const
797
835
  }
798
- const r = await sessionFlightSearch({
799
- from: q.from, to: q.to, date: q.date,
800
- // ADR-15 收尾:ReadGuard 审计在生产工具路径同样落盘(此前仅测试传隔离 stateRoot 才有 JSONL)
801
- auditPath: join(config.stateRoot ?? '.', 'gotry-state', 'session-incidents.jsonl'),
836
+ // 效应解译层(ADR-18):SESSION 通道策略=永不重试/不熔断,节律闸在渠道内;
837
+ // 解译器只做分发与证据拼装,verdict 语义(risk 型 needs-login/challenged)原样透传
838
+ const itp = await interpretEffect({
839
+ effect: 'SESSION_FLIGHT_SEARCH',
840
+ params: {
841
+ from: q.from, to: q.to, date: q.date,
842
+ // ADR-15 收尾:ReadGuard 审计在生产工具路径同样落盘(此前仅测试传隔离 stateRoot 才有 JSONL)
843
+ auditPath: join(config.stateRoot ?? '.', 'gotry-state', 'session-incidents.jsonl'),
844
+ },
802
845
  })
846
+ if (!itp.result) return declinedObservation('SESSION_FLIGHT_SEARCH', itp.trace)
847
+ const r = itp.result
848
+ // issue #46 事实落账(ADR-19):会话面 exact-date 结论同样进事实注册表(独立 source 并列标注)
849
+ const avMapS = await loadAirlineAirportMap()
850
+ await appendFacts(config.stateRoot ?? '.', factsFromSession({ origin: q.from, destination: q.to, date: q.date }, r, new Date().toISOString(), avMapS?.city_alias))
803
851
  const top = (r.options ?? []).slice(0, 8).map(o => `${o.flightNo} ${o.airline} ${o.depDateTime.slice(11, 16)}→${o.arrDateTime.slice(11, 16)} ¥${o.price}`)
804
852
  const summary = r.verdict === 'hit'
805
853
  ? `${q.from}→${q.to} ${q.date} 会话检索(携程,用户本人登录态)前 ${top.length} 条:\n${top.join('\n')}\n${r.evidence}`
@@ -809,7 +857,13 @@ export function apply(ctx: Context, config: Config): void {
809
857
  presentCall: args => ({ card: 'generic', title: `会话检索:${String((args.query as { from?: string })?.from ?? '')}`, kind: 'fetch', rawInput: args.query }),
810
858
  presentResult: (_args, value) => {
811
859
  const r = value as { verdict?: string; options?: unknown[] }
812
- const label = r.verdict === 'hit' ? `会话 ${r.options!.length} 条` : (r.verdict === 'needs-login' ? '需登录' : r.verdict ?? '降级')
860
+ const label = r.verdict === 'hit'
861
+ ? `会话 ${r.options!.length} 条`
862
+ : r.verdict === 'needs-login'
863
+ ? '需登录'
864
+ : r.verdict === 'needs-extension'
865
+ ? '需装扩展(一次性)'
866
+ : r.verdict ?? '降级'
813
867
  return { card: 'generic', title: `会话检索:${label}`, content: [{ type: 'text', text: String((value as { summary?: string }).summary ?? '') }] }
814
868
  },
815
869
  }))
@@ -1073,4 +1127,152 @@ export function apply(ctx: Context, config: Config): void {
1073
1127
  }
1074
1128
  },
1075
1129
  }))
1130
+
1131
+ // ---- 产物面(issue #25):agent 生成的文件可在 dsh 内发现与阅读,不再只是本地文件名 ----
1132
+
1133
+ registerGuarded(defineTool({
1134
+ name: 'gotry_artifacts_list',
1135
+ description:
1136
+ 'List GoTry artifacts — deep-planning deliverables (async runs from the state ledger) plus agent-written markdown files ' +
1137
+ 'in the working directory (trip plans etc.). READ-ONLY discovery. ' +
1138
+ 'Use when the user asks to see/open/revisit a previously generated artifact ' +
1139
+ '(「看看刚才生成的行程」「上次的规划在哪」「打开那个 md」) — list first, then read with gotry_artifacts_read.',
1140
+ parameters: {
1141
+ query: {
1142
+ type: 'json',
1143
+ required: true,
1144
+ description: '{ limit?: 20 }',
1145
+ },
1146
+ },
1147
+ output: {
1148
+ schema: { type: 'json' },
1149
+ render: (_args, value) => [{ type: 'text', text: String((value as { summary?: string }).summary ?? JSON.stringify(value).slice(0, 600)) }],
1150
+ },
1151
+ async execute(args: { query: unknown }, _exec: unknown) {
1152
+ const q = unwrapQuery<{ limit?: number }>(args, 'limit')
1153
+ const r = await listArtifacts({ stateRoot: config.stateRoot ?? '.', limit: q.limit })
1154
+ const lines = r.artifacts.map(a =>
1155
+ `- [${a.source}] ${a.title}${a.status ? `(${a.status})` : ''} — ${a.path}${a.updated ? ` @ ${a.updated.slice(0, 16).replace('T', ' ')}` : ''}`)
1156
+ const summary = r.artifacts.length
1157
+ ? `在册产物 ${r.artifacts.length}/${r.total} 项${r.truncated ? '(截断,可加 limit)' : ''}:\n${lines.join('\n')}`
1158
+ : '无在册产物(异步深度规划交付与工作目录 md 文件都会出现在这里)'
1159
+ return JSON.parse(JSON.stringify({ ok: true, artifacts: r.artifacts, total: r.total, truncated: r.truncated, summary })) as Record<string, never>
1160
+ },
1161
+ presentCall: () => ({ card: 'generic', title: '列出产物', kind: 'search' }),
1162
+ presentResult: (_args, value) => {
1163
+ const r = value as { summary?: string; total?: number }
1164
+ return {
1165
+ card: 'generic',
1166
+ title: `产物:${r.total ?? 0} 项在册`,
1167
+ content: [{ type: 'text', text: String(r.summary ?? '') }],
1168
+ }
1169
+ },
1170
+ }))
1171
+
1172
+ registerGuarded(defineTool({
1173
+ name: 'gotry_artifacts_read',
1174
+ description:
1175
+ 'Read one GoTry artifact as a line-numbered file view rendered directly in the chat UI. ' +
1176
+ 'Input: the path from gotry_artifacts_list, or a bare async ticket id (e.g. dp-xxxx). ' +
1177
+ 'Optional offset (1-based) / limit window for paging large files. ' +
1178
+ 'READ-ONLY; text artifacts only (md/txt/json/jsonl/csv/log/yaml).',
1179
+ parameters: {
1180
+ query: {
1181
+ type: 'json',
1182
+ required: true,
1183
+ description: '{ path: "<list 返回的路径或工单 id>", offset?: 1, limit?: 400 }',
1184
+ },
1185
+ },
1186
+ output: {
1187
+ schema: { type: 'json' },
1188
+ render: (_args, value) => [{ type: 'text', text: String((value as { content?: string }).content ?? JSON.stringify(value).slice(0, 600)) }],
1189
+ },
1190
+ async execute(args: { query: unknown }, _exec: unknown) {
1191
+ const q = unwrapQuery<{ path?: string; offset?: number; limit?: number }>(args, 'path')
1192
+ if (!q.path) return JSON.parse(JSON.stringify({ ok: false, error: 'path 必填(来自 gotry_artifacts_list)' })) as Record<string, never>
1193
+ const r = await readArtifact({ stateRoot: config.stateRoot ?? '.', path: q.path, offset: q.offset, limit: q.limit })
1194
+ if (!r.ok) return JSON.parse(JSON.stringify(r)) as Record<string, never>
1195
+ return JSON.parse(JSON.stringify({
1196
+ ...r,
1197
+ summary: `${r.path}(${r.totalLines} 行)第 ${r.offset}-${r.offset + r.lines.length - 1} 行${r.windowed ? `(共 ${r.totalLines} 行,可翻页)` : ''}`,
1198
+ })) as Record<string, never>
1199
+ },
1200
+ presentCall: args => ({ card: 'generic', title: `读产物:${String((args.query as { path?: string })?.path ?? '')}`, kind: 'read', rawInput: args.query }),
1201
+ presentResult: (_args, value) => {
1202
+ const r = value as unknown as { ok?: boolean; path?: string; offset?: number; lines?: Array<{ number: number; text: string }>; totalLines?: number; lang?: string; content?: string; error?: string }
1203
+ if (!r.ok) {
1204
+ return { card: 'generic', title: '读产物失败', content: [{ type: 'text', text: String(r.error ?? '') }] }
1205
+ }
1206
+ // dsh read 卡:UI 渲染为行号文件视图(issue #25 的「插件能力查看 artifacts」落点)
1207
+ return {
1208
+ card: 'read' as const,
1209
+ title: r.path?.split('/').pop() ?? r.path ?? '',
1210
+ path: r.path ?? '',
1211
+ offset: r.offset ?? 1,
1212
+ lines: r.lines ?? [],
1213
+ totalLines: r.totalLines ?? 0,
1214
+ lang: r.lang,
1215
+ content: [{ type: 'text', text: r.content ?? '' }],
1216
+ }
1217
+ },
1218
+ }))
1219
+
1220
+ // ---- 产物事实闸(issue #46,ADR-19):含可下单事实的产物交付前必过 ----
1221
+ // 每个航班号/时刻/机场/价格/政策断言必须回溯到本会话 exact-date 工具结果
1222
+ // (bookable-facts 侧车,query_id 可重放);无法回溯即 blocked——不得宣称「已验证方案」。
1223
+
1224
+ registerGuarded(defineTool({
1225
+ name: 'gotry_fact_gate',
1226
+ description:
1227
+ 'Factuality gate for itinerary artifacts (issue #46): call BEFORE delivering any artifact that contains bookable facts '
1228
+ + '(flight numbers, times, airports, prices, visa/entry policies). Every bookable claim in the markdown is checked against '
1229
+ + 'the session fact registry (exact-date tool results recorded by gotry_flyai_search / gotry_session_search — hit AND miss). '
1230
+ + 'Blocked when: a claimed flight is absent from the exact-date source (never backfill from route pages/history/adjacent dates); '
1231
+ + 'route+date never queried; times contradict the snapshot; carrier→airport mapping conflicts (FD=DMK vs VZ=BKK are NOT interchangeable); '
1232
+ + '「联程」without protected_connection=true; policy claims without 「截至 YYYY-MM-DD」; unconditional ✓ on unverified claims. '
1233
+ + 'Optional itinerary object enables machine invariants (hotel_nights + onboard_nights = total nights, O&D segments vs flight legs, '
1234
+ + 'budget floor ≥ sum of item minimums). verdict=blocked ⇒ fix or downgrade wording — never present as a verified plan.',
1235
+ parameters: {
1236
+ query: {
1237
+ type: 'json',
1238
+ required: true,
1239
+ description: '{ markdown?: "<产物全文>", path?: "<产物路径(artifacts_list 返回的)>— 二选一", tripYear?: 2027, itinerary?: { trip_start, trip_end, stays: [{place,check_in,check_out}], onboard_nights, od_segments: [{from,to,date,mode,legs}], budget_items: [{label,min_cny,max_cny}], claimed_floor_cny? } }',
1240
+ },
1241
+ },
1242
+ output: {
1243
+ schema: { type: 'json' },
1244
+ render: (_args, value) => [{ type: 'text', text: String((value as { summary?: string }).summary ?? JSON.stringify(value).slice(0, 800)) }],
1245
+ },
1246
+ async execute(args: { query: unknown }, _exec: unknown) {
1247
+ const q = unwrapQuery<{ markdown?: string; path?: string; tripYear?: number; itinerary?: Record<string, unknown> }>(args, 'markdown')
1248
+ const avMap = await loadAirlineAirportMap()
1249
+ if (!avMap) {
1250
+ return JSON.parse(JSON.stringify({ ok: false, summary: '航司机场映射缺失(data/airline-airports.json)——事实闸不可用,fail closed:产物不得宣称已验证' })) as Record<string, never>
1251
+ }
1252
+ let markdown = q.markdown
1253
+ if (!markdown && q.path) {
1254
+ const r = await readArtifact({ stateRoot: config.stateRoot ?? '.', path: q.path })
1255
+ if (!r.ok) return JSON.parse(JSON.stringify({ ok: false, summary: `产物读取失败:${String((r as { error?: string }).error ?? '')}` })) as Record<string, never>
1256
+ markdown = r.content
1257
+ }
1258
+ if (!markdown) return JSON.parse(JSON.stringify({ ok: false, summary: '需要 markdown(产物全文)或 path(产物路径)' })) as Record<string, never>
1259
+ const itinerary = q.itinerary as import('./bookable-facts.ts').ItineraryFacts | undefined
1260
+ const registry = await loadFactRegistry(config.stateRoot ?? '.')
1261
+ const report = gateArtifact(markdown, registry, avMap, { trip_year: q.tripYear, itinerary })
1262
+ const lines = report.violations.slice(0, 20).map(v => ` L${v.line} [${v.kind}] ${v.detail}`)
1263
+ const summary = report.verdict === 'pass'
1264
+ ? `事实闸 PASS:${report.traceable}/${report.claims_checked} 可下单 claim 全部回溯到 exact-date 工具结果(query_id 可重放)——可宣称「已验证方案」。`
1265
+ : `事实闸 BLOCKED(${report.violations.length} 违例,${report.traceable}/${report.claims_checked} claim 可回溯)——不得宣称「已验证方案」:\n${lines.join('\n')}\n修正路径:逐条改成 exact-date 源返回的事实,或降级为「未确认/当前不可售,到 D-xx 复核」;exact-date miss 的 route+date 不得用历史班期/相邻日期/航线页填充。`
1266
+ return JSON.parse(JSON.stringify({ ok: true, ...report, summary })) as Record<string, never>
1267
+ },
1268
+ presentCall: args => ({ card: 'generic', title: '产物事实闸', kind: 'execute', rawInput: args.query }),
1269
+ presentResult: (_args, value) => {
1270
+ const r = value as { verdict?: string; violations?: unknown[]; summary?: string }
1271
+ return {
1272
+ card: 'generic',
1273
+ title: `事实闸:${r.verdict === 'pass' ? '✅ pass' : `⛔ blocked(${r.violations?.length ?? '?'})`}`,
1274
+ content: [{ type: 'text', text: String(r.summary ?? '') }],
1275
+ }
1276
+ },
1277
+ }))
1076
1278
  }