@danceiny/gotry 0.0.1-rc.10

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 (130) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +267 -0
  3. package/bin/gotry-inner.js +214 -0
  4. package/bin/gotry-stdio-ask.js +59 -0
  5. package/bin/gotry.js +28 -0
  6. package/cordis.gotry-patch.yml +86 -0
  7. package/data/flights_2026.json +218 -0
  8. package/data/golden_erhai.json +92 -0
  9. package/data/golden_trip_2026.json +75 -0
  10. package/data/hotels_2026.json +151 -0
  11. package/data/openflights-skeleton.json +895 -0
  12. package/data/time-slot-eval.json +384 -0
  13. package/data/yunnan-pack.json +202 -0
  14. package/dist/capabilities/agent-reach-bridge.py +90 -0
  15. package/dist/capabilities/agent-reach-deep.js +172 -0
  16. package/dist/capabilities/agent-reach.js +204 -0
  17. package/dist/capabilities/anything.js +139 -0
  18. package/dist/capabilities/flyai.js +131 -0
  19. package/dist/capabilities/hbcli.js +143 -0
  20. package/dist/capabilities/incident-log.js +100 -0
  21. package/dist/capabilities/opensky.js +74 -0
  22. package/dist/capabilities/session/action-cache.js +120 -0
  23. package/dist/capabilities/session/adapters/ctrip-flight.js +83 -0
  24. package/dist/capabilities/session/adapters/meituan-local.js +73 -0
  25. package/dist/capabilities/session/extract.js +40 -0
  26. package/dist/capabilities/session/read-guard.js +99 -0
  27. package/dist/capabilities/session/transport.js +101 -0
  28. package/dist/capabilities/session-search.js +93 -0
  29. package/dist/capabilities/weather.js +180 -0
  30. package/dist/scripts/action-cache-tests.js +107 -0
  31. package/dist/scripts/agent-reach-deep-tests.js +54 -0
  32. package/dist/scripts/agent-reach-tests.js +38 -0
  33. package/dist/scripts/agent-reach-wrapper-tests.js +79 -0
  34. package/dist/scripts/anything-tests.js +95 -0
  35. package/dist/scripts/async-collect.js +37 -0
  36. package/dist/scripts/companion-tests.js +112 -0
  37. package/dist/scripts/diff-test.js +39 -0
  38. package/dist/scripts/engine-run.js +14 -0
  39. package/dist/scripts/engine-tests.js +45 -0
  40. package/dist/scripts/hbcli-tests.js +101 -0
  41. package/dist/scripts/incident-tests.js +96 -0
  42. package/dist/scripts/journey-tests.js +45 -0
  43. package/dist/scripts/ledger-tests.js +365 -0
  44. package/dist/scripts/ledger-workflow-crash.js +42 -0
  45. package/dist/scripts/memory-capture-tests.js +77 -0
  46. package/dist/scripts/memory-decay-tests.js +83 -0
  47. package/dist/scripts/memory-metrics.js +24 -0
  48. package/dist/scripts/nudge-digest.js +83 -0
  49. package/dist/scripts/opensky-check.js +20 -0
  50. package/dist/scripts/opensky-tests.js +52 -0
  51. package/dist/scripts/probe-poi-tests.js +88 -0
  52. package/dist/scripts/publish-preverify.js +68 -0
  53. package/dist/scripts/replay-async.js +44 -0
  54. package/dist/scripts/replay-real.js +38 -0
  55. package/dist/scripts/replay.js +116 -0
  56. package/dist/scripts/session-attach-diagnose.js +27 -0
  57. package/dist/scripts/session-attach-poc.js +65 -0
  58. package/dist/scripts/session-extract-tests.js +81 -0
  59. package/dist/scripts/session-tests.js +162 -0
  60. package/dist/scripts/skeleton-check.js +37 -0
  61. package/dist/scripts/skeleton-integration-test.js +22 -0
  62. package/dist/scripts/skills-contract-tests.js +85 -0
  63. package/dist/scripts/smoke.js +328 -0
  64. package/dist/scripts/state-cli-tests.js +136 -0
  65. package/dist/scripts/state-cli.js +248 -0
  66. package/dist/scripts/time-eval-tests.js +318 -0
  67. package/dist/scripts/travel-timeline-tests.js +123 -0
  68. package/dist/scripts/unified-tests.js +49 -0
  69. package/dist/scripts/weather-tests.js +48 -0
  70. package/dist/src/bridge.js +34 -0
  71. package/dist/src/companions.js +112 -0
  72. package/dist/src/contracts.js +64 -0
  73. package/dist/src/dsh-llm.js +174 -0
  74. package/dist/src/engine.js +331 -0
  75. package/dist/src/index.js +1239 -0
  76. package/dist/src/journey.js +147 -0
  77. package/dist/src/loop.js +360 -0
  78. package/dist/src/memory-capture.js +40 -0
  79. package/dist/src/memory-decay.js +31 -0
  80. package/dist/src/memory-utility.js +55 -0
  81. package/dist/src/mock-llm.js +103 -0
  82. package/dist/src/model.js +134 -0
  83. package/dist/src/slot-spec.js +165 -0
  84. package/dist/src/state-ledger.js +901 -0
  85. package/dist/src/time-anchor.js +100 -0
  86. package/dist/src/tool-packet.js +12 -0
  87. package/dist/src/travel-slots.js +144 -0
  88. package/dist/src/travel-timeline.js +78 -0
  89. package/dist/src/unified.js +538 -0
  90. package/dist/src/wish-pool.js +27 -0
  91. package/package.json +84 -0
  92. package/ts/capabilities/agent-reach-deep.ts +114 -0
  93. package/ts/capabilities/agent-reach.ts +158 -0
  94. package/ts/capabilities/anything.ts +201 -0
  95. package/ts/capabilities/flyai.ts +151 -0
  96. package/ts/capabilities/hbcli.ts +152 -0
  97. package/ts/capabilities/incident-log.ts +148 -0
  98. package/ts/capabilities/opensky.ts +121 -0
  99. package/ts/capabilities/session/action-cache.ts +148 -0
  100. package/ts/capabilities/session/adapters/ctrip-flight.ts +107 -0
  101. package/ts/capabilities/session/adapters/meituan-local.ts +70 -0
  102. package/ts/capabilities/session/extract.ts +54 -0
  103. package/ts/capabilities/session/read-guard.ts +141 -0
  104. package/ts/capabilities/session/transport.ts +114 -0
  105. package/ts/capabilities/session-search.ts +129 -0
  106. package/ts/capabilities/weather.ts +176 -0
  107. package/ts/package.json +27 -0
  108. package/ts/scripts/async-collect.ts +50 -0
  109. package/ts/scripts/skeleton-check.ts +49 -0
  110. package/ts/scripts/skeleton-integration-test.ts +23 -0
  111. package/ts/scripts/state-cli.ts +201 -0
  112. package/ts/src/bridge.ts +44 -0
  113. package/ts/src/companions.ts +113 -0
  114. package/ts/src/contracts.ts +165 -0
  115. package/ts/src/dsh-llm.ts +155 -0
  116. package/ts/src/index.ts +968 -0
  117. package/ts/src/loop.ts +411 -0
  118. package/ts/src/memory-capture.ts +59 -0
  119. package/ts/src/memory-decay.ts +55 -0
  120. package/ts/src/memory-utility.ts +70 -0
  121. package/ts/src/mock-llm.ts +100 -0
  122. package/ts/src/model.ts +220 -0
  123. package/ts/src/slot-spec.ts +172 -0
  124. package/ts/src/state-ledger.ts +849 -0
  125. package/ts/src/time-anchor.ts +134 -0
  126. package/ts/src/tool-packet.ts +32 -0
  127. package/ts/src/travel-slots.ts +219 -0
  128. package/ts/src/travel-timeline.ts +83 -0
  129. package/ts/src/unified.ts +582 -0
  130. package/ts/src/wish-pool.ts +66 -0
@@ -0,0 +1,968 @@
1
+ /**
2
+ * GoTry dsh 插件(gotry-tools):把 GoTry 的领域能力注册为 dsh 工具。
3
+ *
4
+ * 对应总纲 3.2 插件清单中的三个最小集:
5
+ * - gotry_feasibility_check 可行性引擎(门到门全成本,bridge → Python Z3)
6
+ * - gotry_motivation_save 动机画像落盘(为什么出发;B2B 接缝的契约对象)
7
+ * - gotry_wish_pool_add 「下一次出发」清单(憧憬不被拒绝)
8
+ *
9
+ * 插件形态遵循 dsh 约定(name/inject/Config/apply + ctx.tools.register(defineTool(...))),
10
+ * 对齐已发布 @deepseek-ai/dsh-tools@0.0.1-rc.1 的契约:
11
+ * render 位于 output 对象内,参数属性是 ValueSchemaSpec(支持 type:'json')。
12
+ *
13
+ * @module @gotry/plugin
14
+ */
15
+
16
+ import { join } from 'node:path'
17
+
18
+ import type { Context } from '@deepseek-ai/cordis'
19
+ import z from '@deepseek-ai/schemastery'
20
+ import { defineTool } from '@deepseek-ai/dsh-tools'
21
+ import { ensureStateDir, recordLatency } from './bridge.ts'
22
+ import { segmentsFromCandidate, solveChoiceSegment } from './unified.ts'
23
+ import { checkConnectivity } from '../scripts/skeleton-check.ts'
24
+ import { parseCandidate, parseRequest } from './model.ts'
25
+ import { searchHotels as hbcliSearchHotels } from '../capabilities/hbcli.ts'
26
+ import { installProcessGuards, guardToolExecute } from '../capabilities/incident-log.ts'
27
+ import { interpretArgs, type GotryObservation } from './tool-packet.ts'
28
+ import { projectUtility } from './memory-utility.ts'
29
+ import { pickNudgeWish, type WishPoolEntry } from './wish-pool.ts'
30
+ import { resolveTimelineDate } from './travel-timeline.ts'
31
+ import { ensureLedger, readCompanionsWithFallback, readMotivationWithFallback, readTripsWithFallback, readWishPoolWithFallback } from './state-ledger.ts'
32
+ import { buildTimeAnchor } from './time-anchor.ts'
33
+ import { resolveSlotDate } from './slot-spec.ts'
34
+ import { geocodePlace, getForecast, getClimate, wmoLabel } from '../capabilities/weather.ts'
35
+ import { verifyFlight } from '../capabilities/opensky.ts'
36
+ import { anythingSearch } from '../capabilities/anything.ts'
37
+ import { readUrl, reach, reachStatus } from '../capabilities/agent-reach.ts'
38
+ 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
+
42
+ export const name = 'gotry-tools'
43
+ export const inject = ['tools', 'systemPrompt']
44
+
45
+ export interface Config {
46
+ /** 状态根目录(动机画像、wish pool、延迟日志) */
47
+ stateRoot: string
48
+ /** 引擎调用超时(ms) */
49
+ timeoutMs: number
50
+ /** hbcli 二进制路径(hotelbyte-cli;空=禁用实时酒店,回退数据包) */
51
+ hbcliBin: string
52
+ }
53
+
54
+ export const Config: z<Config> = z.object({
55
+ stateRoot: z.string().default('.'),
56
+ timeoutMs: z.number().default(30_000),
57
+ hbcliBin: z.string().default('hbcli'),
58
+ })
59
+
60
+ interface FeasibilityResult {
61
+ answer_md?: string
62
+ recommended?: string | null
63
+ verdicts?: Array<Record<string, unknown>>
64
+ }
65
+
66
+ interface MotivationProfileInput {
67
+ weights?: Record<string, number>
68
+ evidence?: string[]
69
+ hard?: Record<string, unknown>
70
+ }
71
+
72
+ interface WishPoolEntryInput {
73
+ name?: string
74
+ reason?: string
75
+ conditions?: Record<string, unknown>
76
+ }
77
+
78
+
79
+ /**
80
+ * query 包装参数的三形态归一(#12/#13):实现移居 tool-packet.ts(interpretArgs,
81
+ * RFC S1 interpretation 层语义归位),此处按旧名引 handy 别名,调用点零漂移。
82
+ */
83
+ const unwrapQuery = interpretArgs
84
+
85
+ /**
86
+ * 动机画像 → persona 紧凑 brief(M4 T1 读回路径):空画像返回 ''(首访),
87
+ * persona 据此决定是否访谈。只读,不猜——画像里没有的字段不编。
88
+ * ADR-15:读经账本(未迁移 root 回退旧文件,只读)。
89
+ */
90
+ function renderMotivationBrief(stateRoot: string): string {
91
+ const p = readMotivationWithFallback(stateRoot)
92
+ if (!p) return '' // 首访:无画像
93
+ const lines: string[] = ['## 用户记忆(跨会话画像;与用户当轮说法冲突时以用户为准,更新经 gotry_motivation_save)']
94
+ const weights = Object.entries(p.weights ?? {})
95
+ if (weights.length) lines.push(`- 动机权重: ${weights.map(([k, v]) => `${k}=${v}`).join(', ')}(证据 ${p.evidence?.length ?? 0} 条)`)
96
+ const hard = Object.entries(p.hard ?? {})
97
+ if (hard.length) lines.push(`- 硬约束: ${hard.map(([k, v]) => `${k}=${String(v)}`).join(', ')}`)
98
+ // 旅行时间线(memory-design P1):去过的地方不再主动推荐,除非用户点名
99
+ const trips = readTimelineTrips(stateRoot)
100
+ if (trips.length) {
101
+ lines.push(`- 去过: ${trips.map(t => `${t.destination}(${t.start.slice(0, 7)})`).join('、')}——去过的地方不再主动推荐,除非用户点名`)
102
+ }
103
+ // 同行人档案(memory-design P2):约束只进排序与行程结构建议,永不硬过滤
104
+ const companions = readCompanions(stateRoot)
105
+ if (companions.length) {
106
+ lines.push(`- 同行人: ${companions.map(c => `${c.label}(${c.brief})`).join('、')}——约束进结构与排序建议,不硬过滤;引用时带当初的原话依据`)
107
+ }
108
+ lines.push(`- 愿望池: 用 gotry_wish_pool_list 按条件召回(0..1),勿直接堆砌`)
109
+ if (p.updated_at) lines.push(`- 更新于: ${p.updated_at}`)
110
+ return weights.length || hard.length ? lines.join('\n') : ''
111
+ }
112
+
113
+ /** 时间线摘要(brief 用):最近 3 次行程(目的地+年月);账本优先,未迁移回退文件 */
114
+ /** 同行人摘要(brief 用):label + 约束串 */
115
+ function readCompanions(stateRoot: string): Array<{ label: string; brief: string }> {
116
+ return readCompanionsWithFallback(stateRoot)
117
+ .map(c => {
118
+ const bits = [c.constraints.mobility, ...(c.constraints.health ?? []), ...(c.constraints.prefs ?? [])].filter(Boolean)
119
+ return { label: c.label, brief: bits.join('/') }
120
+ })
121
+ }
122
+
123
+ function readTimelineTrips(stateRoot: string): Array<{ destination: string; start: string }> {
124
+ return readTripsWithFallback(stateRoot)
125
+ .map(t => ({ destination: t.destination, start: t.start }))
126
+ .sort((a, b) => (a.start < b.start ? 1 : -1))
127
+ .slice(0, 3)
128
+ }
129
+
130
+ type Json = string | number | boolean | null | Json[] | { [k: string]: Json }
131
+ type JsonObject = { [k: string]: Json }
132
+
133
+ export function apply(ctx: Context, config: Config): void {
134
+ // 时间感知:注册动态变量,persona 里用 {{current_date}} 引用。
135
+ // 每次 assemble 时取系统时钟——LLM 始终知道「今天是几号」。
136
+ const sp = (ctx as unknown as Record<string, unknown>)['systemPrompt'] as {
137
+ variable?: (name: string, provider: () => string) => void
138
+ } | undefined
139
+ sp?.variable?.('current_date', () => {
140
+ const d = new Date()
141
+ const ymd = `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`
142
+ const weekdays = ['日', '一', '二', '三', '四', '五', '六']
143
+ return `${ymd} 周${weekdays[d.getDay()]}`
144
+ })
145
+ // 时间锚点卡(time-anchor 层,确定性):相对日期换算的唯一依据——
146
+ // 明天/下周X/下个月中旬/节日都查卡,LLM 不自算(算术只在代码层)。
147
+ sp?.variable?.('time_anchor_card', () => buildTimeAnchor(new Date()).card)
148
+
149
+ // M4 记忆读回(T1 闭环的另一半):motivation-profile 此前只写不读,每个新会话
150
+ // 模型都是盲的、重新访谈——「回访规划时长降 ≥50%」不可能成立。这里把画像
151
+ // 渲染成紧凑 brief 注入 persona;为空 = 首访。与当轮说法冲突时以用户为准。
152
+ sp?.variable?.('motivation_brief', () => renderMotivationBrief(config.stateRoot ?? '.'))
153
+
154
+ // D-NEW 进程护栏(Z3 WASM crash 教训):dsh 0.1.1-rc.1 缺 uncaughtException
155
+ // handler,插件异常穿透即杀进程。我们在 gotry 侧挂护栏:同步 fsync 写事故证据
156
+ // (gotry-state/incidents.jsonl),handler 自身不再抛,不阻塞后续控制流。
157
+ // 不调 process.exit——让 dsh/上级容器决定生死,我们只留现场。
158
+ installProcessGuards(config.stateRoot ?? '.', { uncaughtException: 'gotry-tools', unhandledRejection: 'gotry-tools' })
159
+
160
+ // D-NEW 收尾:全部工具 execute 统一异常隔离——单个工具抛错/拒绝不再沿 cordis
161
+ // 传到 dsh 主循环,降级为结构化错误返回给 LLM + incident 落盘(incident-log.ts)。
162
+ const registerGuarded = (tool: ReturnType<typeof defineTool>): void => {
163
+ const t = { ...(tool as unknown as Record<string, unknown>) }
164
+ if (typeof t.execute === 'function') {
165
+ t.execute = guardToolExecute(String(t.name), config.stateRoot ?? '.', t.execute as (args: never, exec: unknown) => never)
166
+ }
167
+ ctx.tools.register(t as unknown as ReturnType<typeof defineTool>)
168
+ }
169
+
170
+ registerGuarded(defineTool({
171
+ name: 'gotry_feasibility_check',
172
+ description:
173
+ 'Check travel candidates against the user\'s motivation and hard constraints using the '
174
+ + 'door-to-door true-cost engine (wake time, arrival state, energy, usable hours, money). '
175
+ + 'Input is the structured request (motivation weights + hard constraints + window + budget + home hubs) '
176
+ + 'and the candidate list (services/transfers/stay costs/min days): '
177
+ + 'structure { request: { motivation weights, hard constraints, window, budget, home hubs }, candidates: [ { id, label, services, transfers, stay, minDays } ] }. '
178
+ + 'Returns per-candidate verdicts, unsat cores with minimal-modification suggestions, '
179
+ + 'a wish-pool entry for infeasible aspirations, and a ready-to-show markdown answer.',
180
+ parameters: {
181
+ payload: {
182
+ type: 'json',
183
+ required: true,
184
+ description: 'The full engine payload: { request, candidates }.',
185
+ },
186
+ },
187
+ output: {
188
+ schema: { type: 'json' },
189
+ render: (_args, value) => [{
190
+ type: 'text',
191
+ text: String((value as FeasibilityResult).answer_md ?? JSON.stringify(value)),
192
+ }],
193
+ },
194
+ async execute(args: { payload: unknown }, _exec: unknown) {
195
+ // 纯 TS 路径:D-7 后 unified solveChoiceSegment 是唯一求解入口(无 Python 桥、无 z3 WASM
196
+ // 路径——候选形态枚举求解,~6ms/次)。unified 内部有 try-catch 护栏覆盖 wasm 异常。
197
+ const started = Date.now()
198
+ const payload = args.payload as Record<string, unknown>
199
+ const req = parseRequest(payload['request'] as Record<string, unknown>)
200
+ const cands = (payload['candidates'] as Record<string, unknown>[]).map(parseCandidate)
201
+ const spec = segmentsFromCandidate(req, cands)
202
+ const result = solveChoiceSegment(spec, req) as Record<string, unknown>
203
+ const dir = await ensureStateDir(config.stateRoot)
204
+ await recordLatency(join(dir, 'bridge-latency.jsonl'), Date.now() - started, 'feasibility_check:in-process-unified').catch(() => {})
205
+ return { ok: true, ...result, latency_ms: Date.now() - started, via: 'in-process-unified' }
206
+ },
207
+ presentCall: args => ({ card: 'generic', title: 'GoTry 可行性检查(门到门全成本)', kind: 'execute', rawInput: args.payload }),
208
+ presentResult: (args, value) => {
209
+ // D-4 卡片赎回:结果卡不再是裸 JSON —— 逐候选判定 + 全成本 vs 预算紧凑行 + 人话答案
210
+ const r = value as FeasibilityResult
211
+ const budget = ((args.payload as { request?: { budget_cny?: number } })?.request)?.budget_cny
212
+ const lines = (r.verdicts ?? []).map(v => {
213
+ const tc = (v.true_cost ?? {}) as { money_cny?: number }
214
+ const money = typeof tc.money_cny === 'number'
215
+ ? ` ¥${tc.money_cny}/人` + (typeof budget === 'number' ? `(预算 ¥${budget},${tc.money_cny <= budget ? '余' : '超'} ¥${Math.abs(budget - tc.money_cny)})` : '')
216
+ : ''
217
+ return v.feasible
218
+ ? `✅ ${String(v.name ?? v.candidate_id)}${money}${v.candidate_id === r.recommended ? ' ← 推荐' : ''}`
219
+ : `❌ ${String(v.name ?? v.candidate_id)} — ${(Array.isArray(v.unsat_core) ? v.unsat_core.join(',') : '不可行')}`
220
+ })
221
+ return {
222
+ card: 'generic',
223
+ title: `可行性:${r.recommended ? `推荐 ${r.recommended}` : '全部不可行'}`,
224
+ content: [{ type: 'text', text: (lines.length ? lines.join('\n') + '\n\n' : '') + String(r.answer_md ?? '').slice(0, 1500) }],
225
+ }
226
+ },
227
+ }))
228
+
229
+ registerGuarded(defineTool({
230
+ name: 'gotry_motivation_save',
231
+ description:
232
+ 'Persist the traveler\'s motivation profile (the "why depart" contract object). '
233
+ + 'This is the B2B reuse seam: downstream plugins consume only MotivationProfile + constraints, '
234
+ + 'never the principal/sponsor distinction. MERGE semantics (T1): call again with just the NEW '
235
+ + 'facts learned this turn (weights delta optional but MUST bring fresh evidence; evidence = user quotes); '
236
+ + 'existing history is never deleted. Requires evidence on every call (P0 anti-fabrication rule).',
237
+ parameters: {
238
+ profile: {
239
+ type: 'json',
240
+ required: true,
241
+ description: '{ weights: {escape_rest: 0.7, ...}, evidence: [user quotes...], hard: {wake_not_before, min_arrival_energy_pct} }',
242
+ },
243
+ },
244
+ output: {
245
+ // loose json:guard 兜底字段(ok/summary)不被 strict schema 拒(骨架事故同款)
246
+ schema: { type: 'json' },
247
+ render: (_args, value) => [{ type: 'text', text: `动机画像已保存:${String((value as { path?: string }).path ?? '')}` }],
248
+ },
249
+ async execute(args: { profile: unknown }, _exec: unknown) {
250
+ // T1 接线:本工具是增量补丁语义(契约 18——模型每轮把对话新事实带进来),
251
+ // 经 mergeProfile 守门合并进既有画像(追加不删史/幂等/权重变更须伴新证据),
252
+ // 不再整档覆盖。首次调用(无档案)= 全量建立。
253
+ // ADR-15:守门+事件+投影在账本单事务内完成,evidence 红线拒绝即回滚,账本无痕。
254
+ const incoming = (args.profile ?? {}) as { weights?: Record<string, number>; evidence?: string[]; hard?: Record<string, unknown> }
255
+ if (!incoming.evidence?.length) {
256
+ throw new Error('refusing to save a motivation profile without evidence (P0 anti-fabrication rule)')
257
+ }
258
+ const ledger = ensureLedger(config.stateRoot)
259
+ const res = ledger.appendMotivationPatch({ weights: incoming.weights, evidence: incoming.evidence, hard: incoming.hard })
260
+ const profileJson = JSON.parse(JSON.stringify(res.profile)) as JsonObject
261
+ return res.saved
262
+ ? { ok: true, saved: true, path: ledger.dbPath, profile: profileJson, summary: '画像已合并入账本(单事务)' }
263
+ : { ok: true, saved: false, path: ledger.dbPath, profile: profileJson, summary: '无新内容(幂等跳过)' }
264
+ },
265
+ presentCall: args => ({ card: 'generic', title: '保存动机画像', kind: 'edit', rawInput: args.profile }),
266
+ }))
267
+
268
+ registerGuarded(defineTool({
269
+ name: 'gotry_wish_pool_add',
270
+ description:
271
+ 'Add an aspiration to the "next departure" wish pool — the graceful home for infeasible dreams. '
272
+ + 'An entry carries its fulfilment conditions (days needed, budget, best months) so a future '
273
+ + '"next departure" nudge can fire when the window matches. Each entry gets a stable wish_id '
274
+ + '(the memory-utility sidecar keys on it); muted:true puts a wish dormant (永不删除,只是不再召回). 憧憬不被拒绝。',
275
+ parameters: {
276
+ entry: {
277
+ type: 'json',
278
+ required: true,
279
+ description: '{ name, reason?, conditions: { days, budget_cny, best_months }, muted?: boolean }',
280
+ },
281
+ },
282
+ output: {
283
+ // loose json:guard 兜底字段(ok/summary)不被 strict schema 拒(骨架事故同款)
284
+ schema: { type: 'json' },
285
+ render: (_args, value) => {
286
+ const v = value as { total?: number; path?: string }
287
+ return [{ type: 'text', text: `已加入「下一次出发」清单(共 ${v.total ?? '?'} 项):${v.path ?? ''}` }]
288
+ },
289
+ },
290
+ async execute(args: { entry: unknown }, _exec: unknown) {
291
+ const entry = (args.entry ?? {}) as WishPoolEntryInput & { muted?: boolean }
292
+ // 同名憧憬幂等更新(刷新理由与成行条件),不重复入池;wish_id 语义派生自名称,稳定可重放。
293
+ // ADR-15:conditions 红线在账本事务内校验拒绝。
294
+ const ledger = ensureLedger(config.stateRoot)
295
+ const r = ledger.appendWish({ name: entry.name, reason: entry.reason, conditions: entry.conditions, muted: entry.muted })
296
+ return { ok: true, added: r.added, wish_id: r.wish_id, total: r.total, path: ledger.dbPath }
297
+ },
298
+ presentCall: args => ({ card: 'generic', title: '加入「下一次出发」清单', kind: 'edit', rawInput: args.entry }),
299
+ }))
300
+
301
+ registerGuarded(defineTool({
302
+ name: 'gotry_wish_pool_list',
303
+ description:
304
+ 'Surface AT MOST ONE "next departure" wish whose fulfilment conditions match the user\'s current window '
305
+ + '(0..1 rule: never more than one nudge per turn, never push when nothing matches — 憧憬不被拒绝,也不被硬推). '
306
+ + 'Muted wishes never surface. Surfacing records a recalled event in the memory-utility sidecar. '
307
+ + 'action="confirm-outcome" records the user-confirmed real-world outcome (attribution helpful/harmful/neutral) — '
308
+ + 'ONLY pass attribution the user explicitly stated; the agent must never self-attribute usefulness.',
309
+ parameters: {
310
+ query: {
311
+ type: 'json',
312
+ required: true,
313
+ description: '{ action?: "recall"|"confirm-outcome", days?, budgetCny?, month?, wishId?, attribution?: "helpful"|"harmful"|"neutral", detail?, tripStart?: "行程起始(确认成行时挂时间线)" }',
314
+ },
315
+ },
316
+ output: {
317
+ schema: { type: 'json' },
318
+ render: (_args, value) => [{ type: 'text', text: String((value as { summary?: string }).summary ?? '') }],
319
+ },
320
+ async execute(args: { query: unknown }, _exec: unknown) {
321
+ const q = unwrapQuery<{ action?: string; days?: number; budgetCny?: number; month?: number; wishId?: string; attribution?: 'helpful' | 'harmful' | 'neutral'; detail?: string; tripStart?: string }>(args, 'action')
322
+ const ledger = ensureLedger(config.stateRoot)
323
+ const now = new Date().toISOString()
324
+ if (q.action === 'confirm-outcome') {
325
+ if (!q.wishId || !q.attribution) {
326
+ return { ok: false, summary: 'confirm-outcome 需要 wishId + attribution(helpful|harmful|neutral)' } as never
327
+ }
328
+ // 成行确认 = 效用事件 + 可选时间线行程,账本单事务(ADR-15:原两文件写在崩溃时分叉)
329
+ let tripInput: { destination: string; start: string; end?: string; companions?: string[]; source: 'wish-confirmed'; evidence: string } | undefined
330
+ const wish = ledger.readWishPool().find(e => String(e.wish_id) === q.wishId) as { name?: unknown } | undefined
331
+ if (q.tripStart && wish?.name) {
332
+ const anchor = buildTimeAnchor(new Date())
333
+ const start = resolveTimelineDate(q.tripStart, anchor) ?? (/^\d{4}-\d{2}-\d{2}$/.test(q.tripStart) ? q.tripStart : undefined)
334
+ if (start) {
335
+ tripInput = {
336
+ destination: String(wish.name), start, companions: undefined,
337
+ source: 'wish-confirmed', evidence: `wish ${q.wishId} confirm-outcome(${q.attribution})`,
338
+ }
339
+ }
340
+ }
341
+ const r = ledger.confirmOutcome({ wishId: q.wishId, attribution: q.attribution, detail: q.detail, trip: tripInput })
342
+ return { ok: true, recorded: r.recorded, wish_id: q.wishId, status: q.attribution, ...(r.trip ? { trip: r.trip } : {}) } as never
343
+ }
344
+ // recall:0..1 条件匹配(判定归 wish-pool 纯函数),muted 永不召回,无命中不硬推
345
+ const pool = ledger.readWishPool()
346
+ const candidates = pool.filter(e => !e.muted && typeof e.wish_id === 'string')
347
+ const month = q.month ?? new Date().getMonth() + 1
348
+ const match = pickNudgeWish(candidates as WishPoolEntry[], { days: q.days, budgetCny: q.budgetCny, month })
349
+ if (!match) {
350
+ return { ok: true, suggestion: null, summary: `无可成行的憧憬匹配当前窗口(${candidates.length} 条在册,0..1 纪律:不硬推)` } as never
351
+ }
352
+ const { events: next } = ledger.appendUtilityEvent({
353
+ wish_id: match.wishId, kind: 'recalled', ts: now, ctx: 'gotry_wish_pool_list.recall',
354
+ })
355
+ const utility = projectUtility(next)[match.wishId]
356
+ return {
357
+ ok: true,
358
+ suggestion: { wish_id: match.entry['wish_id'], name: match.entry['name'], reason: match.entry['reason'], conditions: match.entry['conditions'], match_score: match.score, hits: match.hits },
359
+ utility: { status: utility?.status ?? 'unknown', recalled: utility?.recalled ?? 1 },
360
+ summary: `「下一次出发」候选(0..1):${String(match.entry['name'])}——成行条件 ${JSON.stringify(match.entry['conditions'])},本次窗口命中 ${match.score}/3 项(${match.hits.join('+')});效用状态 ${utility?.status ?? 'unknown'}`,
361
+ } as never
362
+ },
363
+ presentCall: args => ({ card: 'generic', title: '「下一次出发」召回', kind: 'search', rawInput: args.query }),
364
+ }))
365
+
366
+ registerGuarded(defineTool({
367
+ name: 'gotry_companion_save',
368
+ description:
369
+ 'Save/update a travel companion profile (memory-design M5 layer): constraints that shape itinerary STRUCTURE and ranking — '
370
+ + 'never a hard filter (「爸爸65轻度高血压」→ 不排高海拔/控制步行量;「晕车」→ 优先火车/备提示). '
371
+ + 'evidence MUST be the user\'s verbatim words (append-only, traceable). '
372
+ + 'NEGATIVE LIST: passport/ID/phone numbers are rejected on sight — such fields never enter storage.',
373
+ parameters: {
374
+ companion: {
375
+ type: 'json',
376
+ required: true,
377
+ description: '{ label: "爸爸", constraints: { mobility?: "步行≤4h", health?: ["轻度高血压"], prefs?: ["怕吵"] }, evidence: "<用户原话>" }',
378
+ },
379
+ },
380
+ output: {
381
+ schema: { type: 'json' },
382
+ render: (_args, value) => [{ type: 'text', text: String((value as { summary?: string }).summary ?? '') }],
383
+ },
384
+ async execute(args: { companion: unknown }, _exec: unknown) {
385
+ const c = (args.companion ?? {}) as { label?: string; constraints?: { mobility?: string; health?: string[]; prefs?: string[] }; evidence?: string }
386
+ if (!c.evidence) return { ok: false, summary: 'evidence 必填(用户原话,溯源 P0)' } as never
387
+ // ADR-15:负面清单守卫(upsertCompanion)在账本事务内执行,拒收即回滚
388
+ const ledger = ensureLedger(config.stateRoot)
389
+ const res = ledger.appendCompanion({
390
+ label: String(c.label ?? ''),
391
+ constraints: { mobility: c.constraints?.mobility, health: c.constraints?.health, prefs: c.constraints?.prefs },
392
+ evidence: c.evidence,
393
+ })
394
+ if (!res.appended && res.reason) return { ok: false, summary: res.reason } as never
395
+ return { ok: true, appended: res.appended, companion_id: res.companionId, total: res.total, summary: res.appended ? `同行人已保存:${res.companionId}` : `无新内容(幂等跳过):${res.companionId}` } as never
396
+ },
397
+ presentCall: args => ({ card: 'generic', title: '保存同行人', kind: 'edit', rawInput: args.companion }),
398
+ }))
399
+
400
+ registerGuarded(defineTool({
401
+ name: 'gotry_trip_log',
402
+ description:
403
+ 'Record a PAST trip into the travel timeline (memory-design M4 layer). Use when the user mentions a trip they already took '
404
+ + '(「去年国庆去了大理」) — dates resolve via the time anchor (词表外日期会拒绝,请用户给绝对日期), evidence MUST be the user\'s verbatim words. '
405
+ + 'Append-only and idempotent (same destination+start+source = same trip); overlapping same-destination trips are rejected for human adjudication. '
406
+ + 'Past trips power origin resolution and 「去过不再推」 ranking — never a hard filter.',
407
+ parameters: {
408
+ query: {
409
+ type: 'json',
410
+ required: true,
411
+ description: '{ destination: "大理", start: "2025-10-01 或绝对日期表达", end?: 同上, companions?: ["爸爸"], evidence: "<用户原话>" }',
412
+ },
413
+ },
414
+ output: {
415
+ schema: { type: 'json' },
416
+ render: (_args, value) => [{ type: 'text', text: String((value as { summary?: string }).summary ?? '') }],
417
+ },
418
+ async execute(args: { query: unknown }, _exec: unknown) {
419
+ const q = unwrapQuery<{ destination?: string; start?: string; end?: string; companions?: string[]; evidence?: string }>(args, 'destination')
420
+ if (!q.destination) return { ok: false, summary: 'destination 必填' } as never
421
+ if (!q.evidence) return { ok: false, summary: 'evidence 必填(用户原话,溯源 P0)' } as never
422
+ const anchor = buildTimeAnchor(new Date())
423
+ const start = resolveTimelineDate(q.start ?? '', anchor) ?? (/^\d{4}-\d{2}-\d{2}$/.test(q.start ?? '') ? q.start : undefined)
424
+ if (!start) return { ok: false, summary: `start 无法解析为绝对日期(${q.start ?? '缺'})——请向用户要具体日期,不猜` } as never
425
+ const end = q.end ? (resolveTimelineDate(q.end, anchor) ?? (/^\d{4}-\d{2}-\d{2}$/.test(q.end) ? q.end : undefined)) : undefined
426
+ // ADR-15:appendTrip 守门(必填/绝对日期/重叠冲突即停)在账本事务内执行
427
+ const ledger = ensureLedger(config.stateRoot)
428
+ const res = ledger.appendTripEvent({
429
+ destination: q.destination.trim(), start, end, companions: q.companions,
430
+ source: 'user-verbatim', evidence: q.evidence,
431
+ })
432
+ if (!res.appended && res.reason) return { ok: false, summary: res.reason } as never
433
+ return { ok: true, appended: res.appended, trip_id: res.tripId, total: res.total, summary: res.appended ? `已入时间线:${q.destination} @ ${start}` : `已存在(幂等跳过):${res.tripId}` } as never
434
+ },
435
+ presentCall: args => ({ card: 'generic', title: '记录旅行', kind: 'edit', rawInput: args.query }),
436
+ }))
437
+
438
+ registerGuarded(defineTool({
439
+ name: 'gotry_hotel_search',
440
+ description:
441
+ 'Search hotels via hotelbyte-cli (real-time when hbcli credentials exist, falls back to the static pack with explicit evidence tagging). '
442
+ + 'Input: destination city name + optional dates/occupancy. Dates accept verbatim natural expressions (下周五 / 8.20 / 下周五+3) — '
443
+ + 'the code layer resolves them against the time anchor; unresolved expressions degrade to an undated search with an explicit '
444
+ + 'date_notes entry instead of guessing. Output: hotel list with evidence chain ([realtime-API:hbcli] + fetch timestamp, '
445
+ + 'or [static-pack:estimate]) per the L4 invariant.',
446
+ parameters: {
447
+ query: {
448
+ type: 'json',
449
+ required: true,
450
+ description: '{ destination: "<目的地城市>", checkIn?: "YYYY-MM-DD 或自然表达(下周五/8.20)", checkOut?: 同上, occupancy?: { adults: 2 } }',
451
+ },
452
+ },
453
+ output: {
454
+ schema: { type: 'json' },
455
+ render: (_args, value) => [{ type: 'text', text: String((value as { summary?: string }).summary ?? JSON.stringify(value).slice(0, 400)) }],
456
+ },
457
+ async execute(args: { query: unknown }, _exec: unknown) {
458
+ const q = unwrapQuery<{ destination?: string; checkIn?: string; checkOut?: string; adults?: number }>(args, 'destination')
459
+ if (!q.destination) throw new Error('gotry_hotel_search requires destination')
460
+ const started = Date.now()
461
+ const fallbackPath = join(import.meta.dirname, '..', '..', 'data', 'hotels_2026.json')
462
+ // D-10 切片 B:日期槽位接受逐字自然表达(下周五/8.20/+N),代码层换算(slot-spec);
463
+ // unresolved 不猜——降级为无日期搜索并显式记 note,由模型向用户追问
464
+ const anchor = buildTimeAnchor(new Date())
465
+ const dateNotes: string[] = []
466
+ const resolveDate = (expr?: string): string | undefined => {
467
+ if (!expr) return undefined
468
+ const r = resolveSlotDate(expr, anchor)
469
+ if (!r.date) {
470
+ dateNotes.push(`日期未解析:${r.raw}——请向用户确认具体日期`)
471
+ return undefined
472
+ }
473
+ if (r.raw !== r.date) dateNotes.push(`slot-resolved: ${r.raw} → ${r.date}`)
474
+ return r.date
475
+ }
476
+ const resp = await hbcliSearchHotels(
477
+ { destination: q.destination, checkIn: resolveDate(q.checkIn), checkOut: resolveDate(q.checkOut), adults: q.adults },
478
+ { hbcliBin: config.hbcliBin, timeoutMs: config.timeoutMs, fallbackPath },
479
+ )
480
+ const dir = await ensureStateDir(config.stateRoot)
481
+ const isLive = resp.via === 'hbcli-realtime'
482
+ const evidence = isLive ? resp.evidence : '[静态包:估算]'
483
+ await recordLatency(join(dir, 'bridge-latency.jsonl'), Date.now() - started, `hotel_search:${resp.via}`).catch(() => {})
484
+ const payload = {
485
+ ok: true,
486
+ hotels: resp.hotels ?? null,
487
+ evidence,
488
+ destination: q.destination,
489
+ via: resp.via,
490
+ latency_ms: Date.now() - started,
491
+ summary: resp.summary,
492
+ error: resp.error,
493
+ ...(dateNotes.length ? { date_notes: dateNotes } : {}),
494
+ } as never
495
+ return JSON.parse(JSON.stringify(payload)) as never
496
+ },
497
+ presentCall: args => ({ card: 'generic', title: `酒店搜索:${String((args.query as { destination?: string })?.destination ?? '')}`, kind: 'search', rawInput: args.query }),
498
+ presentResult: (_args, value) => {
499
+ const r = value as { hotels?: unknown[]; via?: string; destination?: string; summary?: string }
500
+ const n = Array.isArray(r.hotels) ? r.hotels.length : 0
501
+ return {
502
+ card: 'generic',
503
+ title: `酒店:${r.destination ?? ''} ${n ? `${n} 家(${r.via === 'hbcli-realtime' ? '实时' : '静态包'})` : '无结果'}`,
504
+ content: [{ type: 'text', text: String(r.summary ?? '') }],
505
+ }
506
+ },
507
+ }))
508
+
509
+ registerGuarded(defineTool({
510
+ name: 'gotry_skeleton_check',
511
+ description:
512
+ 'Check flight connectivity between two airports against the OpenFlights skeleton (free tier). '
513
+ + 'Three-valued: found = strong positive (airlines returned); hub-to-hub absence = downgrade signal, NEVER disproof '
514
+ + '(skeleton lags reality); outside hub set = no conclusion. Use BEFORE recommending a route.',
515
+ parameters: {
516
+ from: { type: 'string', required: true, description: 'IATA, e.g. HKG' },
517
+ to: { type: 'string', required: true, description: 'IATA, e.g. HKT' },
518
+ },
519
+ output: {
520
+ // loose json(与兄弟工具一致):strict additionalProperties:false 会拒 guard 的
521
+ // 错误兜底字段(ok/summary),校验错掩盖真实错误
522
+ schema: { type: 'json' },
523
+ render: (_args, value) => [{ type: 'text', text: String((value as { evidence?: string }).evidence ?? JSON.stringify(value)) }],
524
+ },
525
+ async execute(args: { from?: string; to?: string; query?: unknown } & Record<string, unknown>, _exec: unknown) {
526
+ // 平铺参数的工具也会被 LLM 包进 query(#12/#13 同款形态),unwrapQuery 兜住
527
+ const q = unwrapQuery<{ from?: string; to?: string }>(args)
528
+ const verdict = await checkConnectivity(String(q.from ?? ''), String(q.to ?? ''))
529
+ return JSON.parse(JSON.stringify({ ok: true, ...verdict })) as Record<string, never>
530
+ },
531
+ presentCall: args => ({ card: 'generic', title: `骨架校验:${args.from}-${args.to}`, kind: 'execute', rawInput: args }),
532
+ }))
533
+
534
+ registerGuarded(defineTool({
535
+ name: 'gotry_weather_check',
536
+ description:
537
+ 'Check weather for a destination: forecast (≤16 days) or historical climate (seasonality baseline). '
538
+ + 'Free Open-Meteo API, no key required. Input: place name (Chinese ok) or lat/lng. '
539
+ + 'Returns daily temp range, precipitation probability, weather code — with evidence chain tagging '
540
+ + '[实时API:open-meteo@ts]. Use to ground seasonal advice in real data instead of LLM guessing.',
541
+ parameters: {
542
+ query: {
543
+ type: 'json',
544
+ required: true,
545
+ description: '{ place: "<城市名>", month?: 8, mode?: "forecast"|"climate", days?: 7 }',
546
+ },
547
+ },
548
+ output: {
549
+ schema: { type: 'json' },
550
+ render: (_args, value) => [{ type: 'text', text: String((value as { summary?: string }).summary ?? JSON.stringify(value).slice(0, 600)) }],
551
+ },
552
+ async execute(args: { query: unknown }, _exec: unknown) {
553
+ const q = unwrapQuery<{ place?: string; lat?: number; lng?: number; month?: number; mode?: string; days?: number }>(args, 'place')
554
+ const started = Date.now()
555
+ let lat: number | undefined = q.lat, lng: number | undefined = q.lng
556
+ let placeLabel = q.place ?? `${q.lat},${q.lng}`
557
+ if (lat === undefined || lng === undefined) {
558
+ if (!q.place) throw new Error('gotry_weather_check requires place name or lat/lng')
559
+ const geo = await geocodePlace(q.place)
560
+ if (!geo.ok || geo.results.length === 0) {
561
+ return JSON.parse(JSON.stringify({ ok: false, summary: `地点「${q.place}」地理编码失败:${geo.error ?? '无结果'}`, evidence: geo.evidence })) as Record<string, never>
562
+ }
563
+ const hit = geo.results[0]
564
+ lat = hit.latitude; lng = hit.longitude
565
+ placeLabel = `${hit.name}(${hit.admin1 ?? hit.country ?? ''})`
566
+ }
567
+ const isClimate = q.mode === 'climate' || (q.month !== undefined && q.mode !== 'forecast')
568
+ const r = isClimate
569
+ ? await getClimate({ latitude: lat, longitude: lng }, q.month ?? new Date().getMonth() + 1)
570
+ : await getForecast({ latitude: lat, longitude: lng }, { days: q.days })
571
+ const dir = await ensureStateDir(config.stateRoot)
572
+ await recordLatency(join(dir, 'bridge-latency.jsonl'), Date.now() - started, `weather:${r.via}`).catch(() => {})
573
+ const dailyLines = (r.daily ?? []).slice(0, 7).map(d =>
574
+ `${d.date} ${d.tempMinC.toFixed(0)}–${d.tempMaxC.toFixed(0)}°C ${wmoLabel(d.weatherCode)}${d.precipProbMaxPct !== null ? ` 降水概率${d.precipProbMaxPct}%` : ''}`)
575
+ const summary = r.ok
576
+ ? `${placeLabel}:${isClimate ? '历史气候' : `${q.days ?? 7} 天预报`}\n${dailyLines.join('\n')}\n${r.evidence}`
577
+ : `${placeLabel}:天气查询失败(${r.error})${r.evidence}`
578
+ return JSON.parse(JSON.stringify({
579
+ ok: r.ok, place: placeLabel, mode: isClimate ? 'climate' : 'forecast',
580
+ daily: r.daily, evidence: r.evidence, summary,
581
+ latency_ms: Date.now() - started,
582
+ })) as Record<string, never>
583
+ },
584
+ presentCall: args => ({ card: 'generic', title: `天气:${String((args.query as { place?: string })?.place ?? '')}`, kind: 'fetch', rawInput: args.query }),
585
+ presentResult: (args, value) => {
586
+ const r = value as { summary?: string }
587
+ const place = String((args.query as { place?: string })?.place ?? '')
588
+ const failed = String(r.summary ?? '').includes('降级') || String(r.summary ?? '').includes('unavailable')
589
+ return {
590
+ card: 'generic',
591
+ title: `天气:${place} ${failed ? '降级' : 'ok'}`,
592
+ content: [{ type: 'text', text: String(r.summary ?? '') }],
593
+ }
594
+ },
595
+ }))
596
+
597
+ registerGuarded(defineTool({
598
+ name: 'gotry_flight_verify',
599
+ description:
600
+ 'Verify whether a flight callsign is currently observable on the OpenSky ADS-B network. '
601
+ + 'Free anonymous API (~400 credits/day, 4 req/s burst). Three-valued semantics: '
602
+ + 'observed = strong positive (the aircraft is currently being broadcast); '
603
+ + 'not_observed = no conclusion (ADS-B coverage is limited by geography/altitude — '
604
+ + 'a missing signal does NOT disprove the flight); '
605
+ + 'unavailable = API failure, gracefully degraded. '
606
+ + 'Use to ground "is this flight actually flying right now?" in real data, complementing '
607
+ + 'the OpenFlights skeleton (historical connectivity) and the static flight pack (planned schedule).',
608
+ parameters: {
609
+ query: {
610
+ type: 'json',
611
+ required: true,
612
+ description: '{ callsign: "EK329", airport?: "OMDB", timeoutMs?: 10000 }',
613
+ },
614
+ },
615
+ output: {
616
+ schema: { type: 'json' },
617
+ render: (_args, value) => [{ type: 'text', text: String((value as { summary?: string }).summary ?? JSON.stringify(value).slice(0, 500)) }],
618
+ },
619
+ async execute(args: { query: unknown }, _exec: unknown) {
620
+ const q = unwrapQuery<{ callsign: string; airport?: string; timeoutMs?: number }>(args, 'callsign')
621
+ const started = Date.now()
622
+ if (!q.callsign) {
623
+ return JSON.parse(JSON.stringify({ verdict: 'unavailable', evidence: '[校验不可用:无 callsign]', summary: 'callsign 必填' })) as Record<string, never>
624
+ }
625
+ const r = await verifyFlight({ callsign: q.callsign, airport: q.airport, timeoutMs: q.timeoutMs })
626
+ const dir = await ensureStateDir(config.stateRoot)
627
+ await recordLatency(join(dir, 'bridge-latency.jsonl'), Date.now() - started, `flight_verify:${r.via}`).catch(() => {})
628
+ const summary = r.verdict === 'observed'
629
+ ? `${r.callsign} 当前 ADS-B 观测命中 (${r.hits?.length ?? 0} 架)${r.airport ? ` 在 ${r.airport}` : ''}\n${r.evidence}`
630
+ : r.verdict === 'not_observed'
631
+ ? `${r.callsign} 当前观测列表未见(ADS-B 覆盖有限,不否定该航班存在)\n${r.evidence}`
632
+ : `${r.callsign} OpenSky 不可用:${r.error}\n${r.evidence}`
633
+ return JSON.parse(JSON.stringify({
634
+ ok: true,
635
+ verdict: r.verdict, callsign: r.callsign, airport: r.airport,
636
+ sample_size: r.sampleSize, hits: r.hits, evidence: r.evidence, summary,
637
+ latency_ms: Date.now() - started,
638
+ })) as Record<string, never>
639
+ },
640
+ presentCall: args => ({ card: 'generic', title: `飞行校验:${String((args.query as { callsign?: string })?.callsign ?? '')}`, kind: 'fetch', rawInput: args.query }),
641
+ }))
642
+
643
+ registerGuarded(defineTool({
644
+ name: 'gotry_flyai_search',
645
+ description:
646
+ 'Live flight/train search via Fliggy official FlyAI channel (read-only, no key, booking only via jumpUrl by the human). '
647
+ + 'PRIMARY source for real schedules & prices (RFC user-session-data-rfc). Input: kind=flight|train, from/to city names (Chinese), date YYYY-MM-DD. '
648
+ + 'Evidence [实时API:flyai@ts]. Cross-validate expensive claims against gotry_session_search when it matters.',
649
+ parameters: {
650
+ query: { type: 'json', required: true, description: '{ kind: "flight"|"train", from: "上海", to: "丽江", date: "2026-10-01" }' },
651
+ },
652
+ output: { schema: { type: 'json' }, render: (_a, v) => [{ type: 'text', text: String((v as { summary?: string }).summary ?? JSON.stringify(v).slice(0, 600)) }] },
653
+ async execute(args: { query: unknown }, _exec: unknown) {
654
+ const q = unwrapQuery<{ kind?: string; from?: string; to?: string; date?: string }>(args, 'from')
655
+ const kind = q.kind === 'train' ? 'train' : 'flight'
656
+ if (!q.from || !q.to || !q.date) {
657
+ return { ok: false, summary: '需要 from/to(中文城市名)与 date(YYYY-MM-DD)' } as const
658
+ }
659
+ const r = await flyaiSearch({ kind, origin: q.from, destination: q.to, depDate: q.date })
660
+ 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}`)
661
+ const summary = r.verdict === 'hit'
662
+ ? `${q.from}→${q.to} ${q.date} ${kind === 'flight' ? '机票' : '火车票'}(飞猪官方只读)前 ${top.length} 条:\n${top.join('\n')}\n${r.evidence}`
663
+ : `${q.from}→${q.to} ${q.date} 无结果或失败:${r.error ?? 'miss'} ${r.evidence}`
664
+ return JSON.parse(JSON.stringify({ ...r, kind, summary })) as Record<string, never>
665
+ },
666
+ presentCall: args => ({ card: 'generic', title: `官方检索:${String((args.query as { kind?: string })?.kind ?? 'flight')}`, kind: 'fetch', rawInput: args.query }),
667
+ presentResult: (args, value) => {
668
+ const r = value as { ok?: boolean; options?: unknown[] }
669
+ return { card: 'generic', title: `飞猪检索:${r.ok && (r.options?.length ?? 0) > 0 ? `${r.options!.length} 条` : '降级'}`, content: [{ type: 'text', text: String((value as { summary?: string }).summary ?? '') }] }
670
+ },
671
+ }))
672
+
673
+ registerGuarded(defineTool({
674
+ name: 'gotry_session_search',
675
+ description:
676
+ 'Cross-validation search on the user\'s OWN logged-in browser session (dedicated persistent profile, ReadGuard = physically read-only: '
677
+ + 'write requests are aborted at network layer; agent NEVER touches credentials/captcha; on captcha it stops and returns challenged). '
678
+ + 'Currently ctrip-flight: sniffs the site search API for structured options. Evidence [会话:ctrip-flight@ts]. '
679
+ + 'verdict needs-login = run scripts/session-login.ts once with the human logging in. Rate-limited (≥30s between same-site calls). '
680
+ + 'Use to verify/double-check prices from gotry_flyai_search on the same itinerary (split by 直达/中转).',
681
+ parameters: {
682
+ query: { type: 'json', required: true, description: '{ from: "上海", to: "丽江", date: "2026-10-01" }' },
683
+ },
684
+ output: { schema: { type: 'json' }, render: (_a, v) => [{ type: 'text', text: String((v as { summary?: string }).summary ?? JSON.stringify(v).slice(0, 600)) }] },
685
+ async execute(args: { query: unknown }, _exec: unknown) {
686
+ const q = unwrapQuery<{ from?: string; to?: string; date?: string }>(args, 'from')
687
+ if (!q.from || !q.to || !q.date) {
688
+ return { ok: false, summary: '需要 from/to(中文城市名,词表内)与 date(YYYY-MM-DD)' } as const
689
+ }
690
+ const r = await sessionFlightSearch({
691
+ from: q.from, to: q.to, date: q.date,
692
+ // ADR-15 收尾:ReadGuard 审计在生产工具路径同样落盘(此前仅测试传隔离 stateRoot 才有 JSONL)
693
+ auditPath: join(config.stateRoot ?? '.', 'gotry-state', 'session-incidents.jsonl'),
694
+ })
695
+ 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}`)
696
+ const summary = r.verdict === 'hit'
697
+ ? `${q.from}→${q.to} ${q.date} 会话检索(携程,用户本人登录态)前 ${top.length} 条:\n${top.join('\n')}\n${r.evidence}`
698
+ : `会话检索未取回(${r.verdict}):${r.error ?? ''} ${r.evidence}`
699
+ return JSON.parse(JSON.stringify({ ...r, summary })) as Record<string, never>
700
+ },
701
+ presentCall: args => ({ card: 'generic', title: `会话检索:${String((args.query as { from?: string })?.from ?? '')}`, kind: 'fetch', rawInput: args.query }),
702
+ presentResult: (_args, value) => {
703
+ const r = value as { verdict?: string; options?: unknown[] }
704
+ const label = r.verdict === 'hit' ? `会话 ${r.options!.length} 条` : (r.verdict === 'needs-login' ? '需登录' : r.verdict ?? '降级')
705
+ return { card: 'generic', title: `会话检索:${label}`, content: [{ type: 'text', text: String((value as { summary?: string }).summary ?? '') }] }
706
+ },
707
+ }))
708
+
709
+ registerGuarded(defineTool({
710
+ name: 'gotry_anything_search',
711
+ description:
712
+ 'Travel-domain search via hotel-byte CLI → hotel-be Anything (cities/hotels/destinations) — NOT general web search; for general internet facts use gotry_agent_reach. ' +
713
+ 'Mixed destinations (cities / metropolitan areas / high-level regions) + hotels in one call. ' +
714
+ 'Returns candidates with type, name, optional coordinates and hotel-id. ' +
715
+ 'Three-valued semantics: hit = ≥1 candidate; miss = 0 candidates (try synonyms or contentType=city/hotel); ' +
716
+ 'unavailable = hbcli failed (degraded, never blocks). ' +
717
+ 'Use as the first stop when the user mentions a place/city/hotel name and you need to ground it in real catalog data ' +
718
+ '(OpenFlights skeleton tells you connectivity; Anything tells you what EXISTS at a city/region).',
719
+ parameters: {
720
+ query: {
721
+ type: 'json',
722
+ required: true,
723
+ description: '{ keyword: "<搜索关键词>", contentType?: "city"|"hotel", parentDestinationId?: "?", timeoutMs?: 12000 }',
724
+ },
725
+ },
726
+ output: {
727
+ schema: { type: 'json' },
728
+ render: (_args, value) => [{ type: 'text', text: String((value as { summary?: string }).summary ?? JSON.stringify(value).slice(0, 800)) }],
729
+ },
730
+ async execute(args: { query: unknown }, _exec: unknown) {
731
+ const q = unwrapQuery<{ keyword: string; contentType?: 'city' | 'hotel'; parentDestinationId?: string | number; timeoutMs?: number }>(args, 'keyword')
732
+ const started = Date.now()
733
+ if (!q.keyword) {
734
+ return JSON.parse(JSON.stringify({ ok: false, verdict: 'error', summary: 'keyword 必填', evidence: '[hbcli-anything@error] empty' })) as Record<string, never>
735
+ }
736
+ const r = await anythingSearch(q)
737
+ const dir = await ensureStateDir(config.stateRoot)
738
+ await recordLatency(join(dir, 'bridge-latency.jsonl'), Date.now() - started, `anything:${r.via}`).catch(() => {})
739
+ const top5 = (r.hits ?? []).slice(0, 5)
740
+ const summary = r.verdict === 'hit'
741
+ ? `${q.keyword} → hit (${r.hits?.length ?? 0} 候选项)\n${top5.map((h, i) => ` ${i + 1}. [${h.type}] ${h.name}${h.latitude !== undefined && h.longitude !== undefined ? ` @ (${h.latitude.toFixed(3)},${h.longitude.toFixed(3)})` : ''}`).join('\n')}\n${r.evidence}`
742
+ : r.verdict === 'miss'
743
+ ? `${q.keyword} → miss (酒店-be 一切正常但无候选)\n${r.evidence}`
744
+ : `${q.keyword} → unavailable (${r.error})\n${r.evidence}`
745
+ return JSON.parse(JSON.stringify({
746
+ ok: r.ok, verdict: r.verdict, keyword: q.keyword,
747
+ content_type: q.contentType ?? null,
748
+ total_candidates: r.totalCandidates, hits: r.hits, evidence: r.evidence, summary,
749
+ latency_ms: Date.now() - started,
750
+ })) as Record<string, never>
751
+ },
752
+ presentCall: args => ({ card: 'generic', title: `Anything search:${String((args.query as { keyword?: string })?.keyword ?? '')}`, kind: 'search', rawInput: args.query }),
753
+ presentResult: (_args, value) => {
754
+ const r = value as { hits?: unknown[]; total_candidates?: number; verdict?: string; keyword?: string; summary?: string }
755
+ const n = Array.isArray(r.hits) ? r.hits.length : (r.total_candidates ?? 0)
756
+ return {
757
+ card: 'generic',
758
+ title: `Anything:${r.keyword ?? ''} ${r.verdict === 'hit' ? `${n} hits` : (r.verdict ?? 'no-result')}`,
759
+ content: [{ type: 'text', text: String(r.summary ?? '') }],
760
+ }
761
+ },
762
+ }))
763
+
764
+ registerGuarded(defineTool({
765
+ name: 'gotry_web_search',
766
+ description:
767
+ 'Read any public URL as markdown (Jina Reader, free, no key). ' +
768
+ 'Use as the "last mile" web reader when hotel-be Anything or gotry tools lack the answer. ' +
769
+ 'NOT a general-purpose search engine — only fetches a URL you already know. ' +
770
+ 'Three-valued: ok / error(非法 URL/超时)/not-reachable(r.jina.ai 不可用).' +
771
+ 'Contract with gotry capabilities/anything.ts: 同构(L4 证据链 + 降级不阻塞 + 三值)。',
772
+ parameters: {
773
+ query: {
774
+ type: 'json',
775
+ required: true,
776
+ description: '{ url: "https://example.com", timeoutMs?: 20000 }',
777
+ },
778
+ },
779
+ output: {
780
+ schema: { type: 'json' },
781
+ render: (_args, value) => [{ type: 'text', text: String((value as { content?: string }).content?.slice(0, 800) ?? JSON.stringify(value).slice(0, 800)) }],
782
+ },
783
+ async execute(args: { query: unknown }, _exec: unknown) {
784
+ const q = unwrapQuery<{ url?: string; timeoutMs?: number }>(args, 'url')
785
+ const started = Date.now()
786
+ if (!q.url) {
787
+ return JSON.parse(JSON.stringify({ ok: false, summary: 'url 必填', evidence: '[agent-reach:error] empty url' })) as Record<string, never>
788
+ }
789
+ const r = await readUrl({ url: q.url, timeoutMs: q.timeoutMs })
790
+ const dir = await ensureStateDir(config.stateRoot)
791
+ await recordLatency(join(dir, 'bridge-latency.jsonl'), Date.now() - started, `agent-reach:${r.via}`).catch(() => {})
792
+ const summary = r.ok
793
+ ? `${q.url} → ${r.title ?? '(no title)'} (${r.latencyMs}ms)\n${r.evidence}\n---\n${r.content?.slice(0, 600) ?? ''}`
794
+ : `${q.url} → unavailable (${r.error})\n${r.evidence}`
795
+ return JSON.parse(JSON.stringify({
796
+ ok: r.ok, url: q.url, via: r.via, title: r.title,
797
+ content: r.content, evidence: r.evidence, summary,
798
+ latency_ms: Date.now() - started,
799
+ })) as Record<string, never>
800
+ },
801
+ presentCall: args => ({ card: 'generic', title: `读网页:${String((args.query as { url?: string })?.url ?? '')}`, kind: 'fetch', rawInput: args.query }),
802
+ }))
803
+
804
+ registerGuarded(defineTool({
805
+ name: 'gotry_video_subtitle',
806
+ description:
807
+ 'Extract subtitles from a YouTube/Bilibili video (yt-dlp, optional tool). ' +
808
+ 'If yt-dlp is installed on this machine, returns the subtitle text (vtt, zh-Hans/zh/en preference). ' +
809
+ 'If NOT installed, degrades gracefully with install instructions — never blocks. ' +
810
+ 'Evidence chain: [agent-reach:yt-dlp@ts] / [@not-installed@ts].',
811
+ parameters: {
812
+ query: {
813
+ type: 'json',
814
+ required: true,
815
+ description: '{ url: "https://www.youtube.com/watch?v=...", lang?: "zh-Hans,zh,en" }',
816
+ },
817
+ },
818
+ output: {
819
+ schema: { type: 'json' },
820
+ render: (_args, value) => [{ type: 'text', text: String((value as { summary?: string }).summary ?? JSON.stringify(value).slice(0, 600)) }],
821
+ },
822
+ async execute(args: { query: unknown }, _exec: unknown) {
823
+ const q = unwrapQuery<{ url?: string; lang?: string }>(args, 'url')
824
+ if (!q.url) {
825
+ return JSON.parse(JSON.stringify({ ok: false, summary: 'url 必填' })) as Record<string, never>
826
+ }
827
+ const r = await videoSubtitle({ url: q.url, lang: q.lang })
828
+ const summary = r.verdict === 'found'
829
+ ? `${q.url} 字幕提取成功 (${r.latencyMs}ms)\n${r.evidence}\n---\n${(r.subtitles ?? '').slice(0, 800)}`
830
+ : r.verdict === 'not-installed'
831
+ ? `yt-dlp 未安装。${r.stderr}\n${r.evidence}`
832
+ : `${q.url} 字幕提取失败(${r.verdict})\n${r.evidence}`
833
+ return JSON.parse(JSON.stringify({
834
+ ok: r.ok, verdict: r.verdict, url: q.url,
835
+ subtitles: r.subtitles?.slice(0, 4000), evidence: r.evidence, summary,
836
+ latency_ms: r.latencyMs,
837
+ })) as Record<string, never>
838
+ },
839
+ presentCall: args => ({ card: 'generic', title: `视频字幕:${String((args.query as { url?: string })?.url ?? '')}`, kind: 'fetch', rawInput: args.query }),
840
+ }))
841
+
842
+ registerGuarded(defineTool({
843
+ name: 'gotry_github_search',
844
+ description:
845
+ 'Search GitHub repositories (gh CLI, optional tool). ' +
846
+ 'If gh is installed and authenticated, returns repos with name/description/stars/url. ' +
847
+ 'If NOT installed, degrades with install instructions — never blocks. ' +
848
+ 'Evidence chain: [agent-reach:gh@ts] / [@not-installed@ts].',
849
+ parameters: {
850
+ query: {
851
+ type: 'json',
852
+ required: true,
853
+ description: '{ query: "agent-reach", limit?: 5 }',
854
+ },
855
+ },
856
+ output: {
857
+ schema: { type: 'json' },
858
+ render: (_args, value) => [{ type: 'text', text: String((value as { summary?: string }).summary ?? JSON.stringify(value).slice(0, 600)) }],
859
+ },
860
+ async execute(args: { query: unknown }, _exec: unknown) {
861
+ const q = unwrapQuery<{ query?: string; limit?: number }>(args, 'query')
862
+ if (!q.query) {
863
+ return JSON.parse(JSON.stringify({ ok: false, summary: 'query 必填' })) as Record<string, never>
864
+ }
865
+ const r = await githubSearch({ query: q.query, limit: q.limit })
866
+ const summary = r.verdict === 'found'
867
+ ? `${q.query} → ${r.repos?.length ?? 0} repos\n${(r.repos ?? []).map((x, i) => ` ${i + 1}. ${x.name} ★${x.stars ?? '?'} — ${(x.description ?? '').slice(0, 60)}`).join('\n')}\n${r.evidence}`
868
+ : r.verdict === 'not-installed'
869
+ ? `gh 未安装。${r.stderr}\n${r.evidence}`
870
+ : `${q.query} 搜索失败(${r.verdict})\n${r.evidence}`
871
+ return JSON.parse(JSON.stringify({
872
+ ok: r.ok, verdict: r.verdict, query: q.query,
873
+ repos: r.repos, evidence: r.evidence, summary,
874
+ latency_ms: r.latencyMs,
875
+ })) as Record<string, never>
876
+ },
877
+ presentCall: args => ({ card: 'generic', title: `GitHub 搜索:${String((args.query as { query?: string })?.query ?? '')}`, kind: 'search', rawInput: args.query }),
878
+ }))
879
+
880
+ registerGuarded(defineTool({
881
+ name: 'gotry_agent_reach',
882
+ description:
883
+ 'Agent Reach — the PRIMARY external-data gateway (thin wrapper over Panniantong/Agent-Reach upstream registry, zero channel knowledge here). ' +
884
+ 'Prefer this for ANY external/internet fact beyond weather/flights/hotels. ' +
885
+ 'Call ANY upstream channel method by reflection: web.read(url) / v2ex.get_hot_topics() / v2ex.search(query) / ' +
886
+ 'xueqiu.get_stock_quote(symbol) / xueqiu.search_stock(query) / youtube.transcribe(url) / <channel>.check() ... ' +
887
+ 'Unknown channel or method? Just call it — the error returns the upstream inventory (channel list or method signatures) so you can self-correct. ' +
888
+ 'Action "status" runs the real `agent-reach doctor` (.venv/bin/agent-reach). ' +
889
+ 'Channels needing cookies/setup return the upstream check() guidance verbatim — do NOT give up there: hand the user the exact configure command, then offer to re-check. ' +
890
+ 'Evidence chain: [agent-reach:<channel>.<method>@ts].',
891
+ parameters: {
892
+ query: {
893
+ type: 'json',
894
+ required: true,
895
+ description: '{ action: "status" } 或 { action: "reach", channel: "<上游渠道名,如 web/v2ex/xueqiu>", method: "<上游方法名,如 read/get_hot_topics/get_stock_quote>", args?: "<空格分隔参数>" }',
896
+ },
897
+ },
898
+ output: {
899
+ schema: { type: 'json' },
900
+ render: (_args, value) => [{ type: 'text', text: String((value as { summary?: string }).summary ?? JSON.stringify(value).slice(0, 800)) }],
901
+ },
902
+ async execute(args: { query: unknown }, _exec: unknown) {
903
+ const q = unwrapQuery<{ action?: string; channel?: string; method?: string; args?: string; timeoutMs?: number }>(args, 'channel')
904
+ const started = Date.now()
905
+ const dir = await ensureStateDir(config.stateRoot)
906
+
907
+ if (q.action === 'status' || (!q.action && !q.channel)) {
908
+ const st = await reachStatus(q.timeoutMs)
909
+ await recordLatency(join(dir, 'bridge-latency.jsonl'), Date.now() - started, 'agent-reach:doctor').catch(() => {})
910
+ const summary = st.via === 'agent-reach-cli'
911
+ ? `Agent Reach doctor(上游 CLI,原样透传):\n${st.output}\n${st.evidence}`
912
+ : `Agent Reach 未装:\n${st.output}\n${st.evidence}`
913
+ return JSON.parse(JSON.stringify({
914
+ ok: st.ok, via: st.via, output: st.output, evidence: st.evidence, summary,
915
+ latency_ms: Date.now() - started,
916
+ })) as Record<string, never>
917
+ }
918
+
919
+ if (!q.channel || !q.method) {
920
+ return JSON.parse(JSON.stringify({ ok: false, summary: 'channel 与 method 必填(或 action=status);清单可先随便调一次,inventory 会带回上游渠道/方法表' })) as Record<string, never>
921
+ }
922
+ const r = await reach({ channel: q.channel, method: q.method, args: q.args, timeoutMs: q.timeoutMs })
923
+ // 长结果干净截断:数组按条目边界保留(dsh 工具上限会拦腰断 JSON,模型只能看到半条)
924
+ const dataStr = (v: unknown): unknown => {
925
+ if (Array.isArray(v)) {
926
+ const kept: unknown[] = []
927
+ let budget = 3500
928
+ for (const item of v) {
929
+ const s = JSON.stringify(item)
930
+ if (budget - s.length < 0) break
931
+ budget -= s.length + 1
932
+ kept.push(item)
933
+ }
934
+ if (kept.length < v.length) kept.push(`…(截断:保留 ${kept.length}/${v.length} 条;可用上游方法带 limit 参数取更少)`)
935
+ return kept
936
+ }
937
+ if (typeof v === 'string') return v.length > 4000 ? v.slice(0, 4000) + '…(截断)' : v
938
+ return v
939
+ }
940
+ if (r.ok) r.data = dataStr(r.data)
941
+ await recordLatency(join(dir, 'bridge-latency.jsonl'), Date.now() - started, `agent-reach:${q.channel}.${q.method}:${r.verdict}`).catch(() => {})
942
+ const summary = r.verdict === 'found'
943
+ ? `${q.channel}.${q.method} → found (${r.latencyMs}ms)\n${r.evidence}\n${typeof r.data === 'string' ? r.data.slice(0, 600) : JSON.stringify(r.data ?? null).slice(0, 600)}`
944
+ : r.verdict === 'needs-setup'
945
+ ? `${q.channel}.${q.method} → 需配置(上游 check() 原话): ${r.setup ?? ''}\n${r.evidence}`
946
+ : r.verdict === 'not-installed'
947
+ ? `${q.channel}.${q.method} → 上游未装: ${r.setup ?? ''}\n${r.evidence}`
948
+ : `${q.channel}.${q.method} → ${r.error ?? 'error'}${r.inventory ? `\n上游清单: ${JSON.stringify(r.inventory).slice(0, 1200)}` : ''}\n${r.evidence}`
949
+ return JSON.parse(JSON.stringify({
950
+ ok: r.ok, channel: r.channel, method: q.method, verdict: r.verdict,
951
+ data: typeof r.data === 'string' ? r.data.slice(0, 4000) : r.data,
952
+ inventory: r.inventory, setup: r.setup, evidence: r.evidence, summary,
953
+ latency_ms: Date.now() - started,
954
+ })) as Record<string, never>
955
+ },
956
+ presentCall: args => ({ card: 'generic', title: `Agent Reach:${String((args.query as { channel?: string; method?: string })?.channel ?? '')}.${String((args.query as { method?: string })?.method ?? 'status')}`, kind: 'fetch', rawInput: args.query }),
957
+ presentResult: (args, value) => {
958
+ const r = value as { verdict?: string; summary?: string }
959
+ const q = (args.query as { channel?: string; method?: string }) ?? {}
960
+ const icon = r.verdict === 'found' ? '✅' : r.verdict === 'needs-setup' ? '🔧' : r.verdict === 'not-installed' ? '📦' : '❌'
961
+ return {
962
+ card: 'generic',
963
+ title: `AgentReach ${icon} ${q.channel ?? ''}.${q.method ?? 'status'} ${r.verdict ?? ''}`,
964
+ content: [{ type: 'text', text: String(r.summary ?? '') }],
965
+ }
966
+ },
967
+ }))
968
+ }