@danceiny/gotry 0.0.1-rc.7 → 0.0.1-rc.9

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 (75) hide show
  1. package/README.md +29 -74
  2. package/bin/gotry-inner.js +50 -1
  3. package/bin/gotry-stdio-ask.js +59 -0
  4. package/cordis.gotry-patch.yml +63 -5
  5. package/data/flights_2026.json +7 -4
  6. package/data/hotels_2026.json +102 -15
  7. package/data/time-slot-eval.json +384 -0
  8. package/data/yunnan-pack.json +2 -1
  9. package/dist/capabilities/agent-reach-bridge.py +90 -0
  10. package/dist/capabilities/anything.js +2 -2
  11. package/dist/capabilities/flyai.js +131 -0
  12. package/dist/capabilities/hbcli.js +11 -6
  13. package/dist/capabilities/incident-log.js +2 -1
  14. package/dist/capabilities/session/action-cache.js +120 -0
  15. package/dist/capabilities/session/adapters/ctrip-flight.js +83 -0
  16. package/dist/capabilities/session/adapters/meituan-local.js +73 -0
  17. package/dist/capabilities/session/extract.js +40 -0
  18. package/dist/capabilities/session/read-guard.js +56 -0
  19. package/dist/capabilities/session/transport.js +95 -0
  20. package/dist/capabilities/session-search.js +95 -0
  21. package/dist/scripts/action-cache-tests.js +107 -0
  22. package/dist/scripts/agent-reach-tests.js +1 -1
  23. package/dist/scripts/agent-reach-wrapper-tests.js +1 -1
  24. package/dist/scripts/async-collect.js +26 -7
  25. package/dist/scripts/companion-tests.js +112 -0
  26. package/dist/scripts/hbcli-tests.js +18 -2
  27. package/dist/scripts/ledger-tests.js +344 -0
  28. package/dist/scripts/ledger-workflow-crash.js +42 -0
  29. package/dist/scripts/memory-capture-tests.js +77 -0
  30. package/dist/scripts/memory-decay-tests.js +83 -0
  31. package/dist/scripts/memory-metrics.js +24 -0
  32. package/dist/scripts/nudge-digest.js +83 -0
  33. package/dist/scripts/probe-poi-tests.js +9 -0
  34. package/dist/scripts/replay.js +73 -1
  35. package/dist/scripts/session-attach-diagnose.js +31 -0
  36. package/dist/scripts/session-attach-poc.js +65 -0
  37. package/dist/scripts/session-attach-wait.js +41 -0
  38. package/dist/scripts/session-extract-tests.js +81 -0
  39. package/dist/scripts/session-login.js +48 -0
  40. package/dist/scripts/session-tests.js +162 -0
  41. package/dist/scripts/skeleton-check.js +3 -1
  42. package/dist/scripts/skills-contract-tests.js +85 -0
  43. package/dist/scripts/smoke.js +242 -4
  44. package/dist/scripts/state-cli-tests.js +136 -0
  45. package/dist/scripts/state-cli.js +234 -0
  46. package/dist/scripts/time-eval-tests.js +318 -0
  47. package/dist/scripts/travel-timeline-tests.js +123 -0
  48. package/dist/scripts/unified-tests.js +1 -1
  49. package/dist/src/companions.js +112 -0
  50. package/dist/src/dsh-llm.js +27 -7
  51. package/dist/src/index.js +600 -121
  52. package/dist/src/loop.js +89 -25
  53. package/dist/src/memory-capture.js +40 -0
  54. package/dist/src/memory-decay.js +31 -0
  55. package/dist/src/memory-utility.js +55 -0
  56. package/dist/src/mock-llm.js +6 -1
  57. package/dist/src/slot-spec.js +165 -0
  58. package/dist/src/state-ledger.js +848 -0
  59. package/dist/src/time-anchor.js +100 -0
  60. package/dist/src/tool-packet.js +12 -0
  61. package/dist/src/travel-slots.js +144 -0
  62. package/dist/src/travel-timeline.js +78 -0
  63. package/dist/src/unified.js +3 -1
  64. package/dist/src/wish-pool.js +27 -0
  65. package/package.json +6 -2
  66. package/ts/capabilities/hbcli.ts +6 -6
  67. package/ts/capabilities/incident-log.ts +6 -3
  68. package/ts/package.json +3 -0
  69. package/ts/scripts/skeleton-check.ts +5 -1
  70. package/ts/src/dsh-llm.ts +27 -7
  71. package/ts/src/index.ts +439 -91
  72. package/ts/src/loop.ts +109 -34
  73. package/ts/src/mock-llm.ts +13 -1
  74. package/ts/src/unified.ts +6 -1
  75. package/ts/cordis.gotry-patch.yml +0 -36
package/ts/src/index.ts CHANGED
@@ -14,20 +14,30 @@
14
14
  */
15
15
 
16
16
  import { join } from 'node:path'
17
+
17
18
  import type { Context } from '@deepseek-ai/cordis'
18
19
  import z from '@deepseek-ai/schemastery'
19
20
  import { defineTool } from '@deepseek-ai/dsh-tools'
20
- import { ensureStateDir, readJson, recordLatency, writeJson } from './bridge.ts'
21
+ import { ensureStateDir, recordLatency } from './bridge.ts'
21
22
  import { segmentsFromCandidate, solveChoiceSegment } from './unified.ts'
22
23
  import { checkConnectivity } from '../scripts/skeleton-check.ts'
23
24
  import { parseCandidate, parseRequest } from './model.ts'
24
25
  import { searchHotels as hbcliSearchHotels } from '../capabilities/hbcli.ts'
25
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'
26
34
  import { geocodePlace, getForecast, getClimate, wmoLabel } from '../capabilities/weather.ts'
27
35
  import { verifyFlight } from '../capabilities/opensky.ts'
28
36
  import { anythingSearch } from '../capabilities/anything.ts'
29
37
  import { readUrl, reach, reachStatus } from '../capabilities/agent-reach.ts'
30
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'
31
41
 
32
42
  export const name = 'gotry-tools'
33
43
  export const inject = ['tools', 'systemPrompt']
@@ -49,6 +59,8 @@ export const Config: z<Config> = z.object({
49
59
 
50
60
  interface FeasibilityResult {
51
61
  answer_md?: string
62
+ recommended?: string | null
63
+ verdicts?: Array<Record<string, unknown>>
52
64
  }
53
65
 
54
66
  interface MotivationProfileInput {
@@ -63,6 +75,58 @@ interface WishPoolEntryInput {
63
75
  conditions?: Record<string, unknown>
64
76
  }
65
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
+
66
130
  type Json = string | number | boolean | null | Json[] | { [k: string]: Json }
67
131
  type JsonObject = { [k: string]: Json }
68
132
 
@@ -78,6 +142,14 @@ export function apply(ctx: Context, config: Config): void {
78
142
  const weekdays = ['日', '一', '二', '三', '四', '五', '六']
79
143
  return `${ymd} 周${weekdays[d.getDay()]}`
80
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 ?? '.'))
81
153
 
82
154
  // D-NEW 进程护栏(Z3 WASM crash 教训):dsh 0.1.1-rc.1 缺 uncaughtException
83
155
  // handler,插件异常穿透即杀进程。我们在 gotry 侧挂护栏:同步 fsync 写事故证据
@@ -101,7 +173,8 @@ export function apply(ctx: Context, config: Config): void {
101
173
  'Check travel candidates against the user\'s motivation and hard constraints using the '
102
174
  + 'door-to-door true-cost engine (wake time, arrival state, energy, usable hours, money). '
103
175
  + 'Input is the structured request (motivation weights + hard constraints + window + budget + home hubs) '
104
- + 'and the candidate list (services/transfers/stay costs/min days), same shape as data/golden_erhai.json. '
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 } ] }. '
105
178
  + 'Returns per-candidate verdicts, unsat cores with minimal-modification suggestions, '
106
179
  + 'a wish-pool entry for infeasible aspirations, and a ready-to-show markdown answer.',
107
180
  parameters: {
@@ -129,9 +202,28 @@ export function apply(ctx: Context, config: Config): void {
129
202
  const result = solveChoiceSegment(spec, req) as Record<string, unknown>
130
203
  const dir = await ensureStateDir(config.stateRoot)
131
204
  await recordLatency(join(dir, 'bridge-latency.jsonl'), Date.now() - started, 'feasibility_check:in-process-unified').catch(() => {})
132
- return { ...result, latency_ms: Date.now() - started, via: 'in-process-unified' }
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
+ }
133
226
  },
134
- presentCall: args => ({ card: 'generic', title: 'GoTry 可行性检查(门到门全成本)', kind: 'other', rawInput: args.payload }),
135
227
  }))
136
228
 
137
229
  registerGuarded(defineTool({
@@ -139,8 +231,9 @@ export function apply(ctx: Context, config: Config): void {
139
231
  description:
140
232
  'Persist the traveler\'s motivation profile (the "why depart" contract object). '
141
233
  + 'This is the B2B reuse seam: downstream plugins consume only MotivationProfile + constraints, '
142
- + 'never the principal/sponsor distinction. Requires an evidence field: every weight must trace '
143
- + 'back to the user\'s own words (P0 anti-fabrication rule).',
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).',
144
237
  parameters: {
145
238
  profile: {
146
239
  type: 'json',
@@ -149,30 +242,27 @@ export function apply(ctx: Context, config: Config): void {
149
242
  },
150
243
  },
151
244
  output: {
152
- schema: {
153
- type: 'object',
154
- additionalProperties: false,
155
- properties: {
156
- saved: { type: 'boolean' },
157
- path: { type: 'string' },
158
- profile: { type: 'json' },
159
- },
160
- },
245
+ // loose json:guard 兜底字段(ok/summary)不被 strict schema 拒(骨架事故同款)
246
+ schema: { type: 'json' },
161
247
  render: (_args, value) => [{ type: 'text', text: `动机画像已保存:${String((value as { path?: string }).path ?? '')}` }],
162
248
  },
163
249
  async execute(args: { profile: unknown }, _exec: unknown) {
164
- const profile = (args.profile ?? {}) as MotivationProfileInput
165
- if (!profile.evidence?.length) {
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) {
166
256
  throw new Error('refusing to save a motivation profile without evidence (P0 anti-fabrication rule)')
167
257
  }
168
- const dir = await ensureStateDir(config.stateRoot)
169
- const path = join(dir, 'motivation-profile.json')
170
- // JSON 往返保证落盘值与输出值都是 JsonValue(输出 schema 的硬要求)
171
- const saved = JSON.parse(JSON.stringify({ ...profile, updated_at: new Date().toISOString() })) as JsonObject
172
- await writeJson(path, saved)
173
- return { saved: true, path, profile: saved }
174
- },
175
- presentCall: args => ({ card: 'generic', title: '保存动机画像', kind: 'other', rawInput: args.profile }),
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 }),
176
266
  }))
177
267
 
178
268
  registerGuarded(defineTool({
@@ -180,62 +270,184 @@ export function apply(ctx: Context, config: Config): void {
180
270
  description:
181
271
  'Add an aspiration to the "next departure" wish pool — the graceful home for infeasible dreams. '
182
272
  + 'An entry carries its fulfilment conditions (days needed, budget, best months) so a future '
183
- + '"next departure" nudge can fire when the window matches. 憧憬不被拒绝。',
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 (永不删除,只是不再召回). 憧憬不被拒绝。',
184
275
  parameters: {
185
276
  entry: {
186
277
  type: 'json',
187
278
  required: true,
188
- description: '{ name, reason, conditions: { days, budget_cny, best_months } }',
279
+ description: '{ name, reason?, conditions: { days, budget_cny, best_months }, muted?: boolean }',
189
280
  },
190
281
  },
191
282
  output: {
192
- schema: {
193
- type: 'object',
194
- additionalProperties: false,
195
- properties: {
196
- added: { type: 'boolean' },
197
- total: { type: 'integer' },
198
- path: { type: 'string' },
199
- },
200
- },
283
+ // loose json:guard 兜底字段(ok/summary)不被 strict schema 拒(骨架事故同款)
284
+ schema: { type: 'json' },
201
285
  render: (_args, value) => {
202
286
  const v = value as { total?: number; path?: string }
203
287
  return [{ type: 'text', text: `已加入「下一次出发」清单(共 ${v.total ?? '?'} 项):${v.path ?? ''}` }]
204
288
  },
205
289
  },
206
290
  async execute(args: { entry: unknown }, _exec: unknown) {
207
- const entry = (args.entry ?? {}) as WishPoolEntryInput
208
- if (!entry.name || !entry.conditions) {
209
- throw new Error('wish pool entry requires name and conditions (fulfilment conditions are the whole point)')
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
210
343
  }
211
- const dir = await ensureStateDir(config.stateRoot)
212
- const path = join(dir, 'wish-pool.json')
213
- const pool = (await readJson(path, [])) as Array<Record<string, unknown>>
214
- // 同名憧憬幂等更新(刷新理由与成行条件),不重复落盘
215
- const existing = pool.findIndex(e => e['name'] === entry.name)
216
- if (existing >= 0) {
217
- pool[existing] = { ...pool[existing], reason: entry.reason ?? pool[existing]?.['reason'], conditions: entry.conditions }
218
- await writeJson(path, pool)
219
- return { added: false, total: pool.length, path }
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
220
351
  }
221
- pool.push({ reason: '', ...entry, added_at: new Date().toISOString() })
222
- await writeJson(path, pool)
223
- return { added: true, total: pool.length, path }
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
+ },
224
413
  },
225
- presentCall: args => ({ card: 'generic', title: '加入「下一次出发」清单', kind: 'other', rawInput: args.entry }),
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 }),
226
436
  }))
227
437
 
228
438
  registerGuarded(defineTool({
229
439
  name: 'gotry_hotel_search',
230
440
  description:
231
441
  'Search hotels via hotelbyte-cli (real-time when hbcli credentials exist, falls back to the static pack with explicit evidence tagging). '
232
- + 'Input: destination city name + optional dates/occupancy. Output: hotel list with evidence chain ([realtime-API:hbcli] + fetch timestamp, '
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, '
233
445
  + 'or [static-pack:estimate]) per the L4 invariant.',
234
446
  parameters: {
235
447
  query: {
236
448
  type: 'json',
237
449
  required: true,
238
- description: '{ destination: "普吉", checkIn?: "2026-07-18", checkOut?: "2026-07-23", occupancy?: { adults: 2 } }',
450
+ description: '{ destination: "<目的地城市>", checkIn?: "YYYY-MM-DD 或自然表达(下周五/8.20)", checkOut?: 同上, occupancy?: { adults: 2 } }',
239
451
  },
240
452
  },
241
453
  output: {
@@ -243,12 +455,26 @@ export function apply(ctx: Context, config: Config): void {
243
455
  render: (_args, value) => [{ type: 'text', text: String((value as { summary?: string }).summary ?? JSON.stringify(value).slice(0, 400)) }],
244
456
  },
245
457
  async execute(args: { query: unknown }, _exec: unknown) {
246
- const q = (args.query ?? {}) as { destination?: string; checkIn?: string; checkOut?: string; adults?: number }
458
+ const q = unwrapQuery<{ destination?: string; checkIn?: string; checkOut?: string; adults?: number }>(args, 'destination')
247
459
  if (!q.destination) throw new Error('gotry_hotel_search requires destination')
248
460
  const started = Date.now()
249
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
+ }
250
476
  const resp = await hbcliSearchHotels(
251
- { destination: q.destination, checkIn: q.checkIn, checkOut: q.checkOut, adults: q.adults },
477
+ { destination: q.destination, checkIn: resolveDate(q.checkIn), checkOut: resolveDate(q.checkOut), adults: q.adults },
252
478
  { hbcliBin: config.hbcliBin, timeoutMs: config.timeoutMs, fallbackPath },
253
479
  )
254
480
  const dir = await ensureStateDir(config.stateRoot)
@@ -256,6 +482,7 @@ export function apply(ctx: Context, config: Config): void {
256
482
  const evidence = isLive ? resp.evidence : '[静态包:估算]'
257
483
  await recordLatency(join(dir, 'bridge-latency.jsonl'), Date.now() - started, `hotel_search:${resp.via}`).catch(() => {})
258
484
  const payload = {
485
+ ok: true,
259
486
  hotels: resp.hotels ?? null,
260
487
  evidence,
261
488
  destination: q.destination,
@@ -263,10 +490,20 @@ export function apply(ctx: Context, config: Config): void {
263
490
  latency_ms: Date.now() - started,
264
491
  summary: resp.summary,
265
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 ?? '') }],
266
505
  }
267
- return JSON.parse(JSON.stringify(payload)) as Record<string, unknown>
268
506
  },
269
- presentCall: args => ({ card: 'generic', title: `酒店搜索:${String((args.query as { destination?: string })?.destination ?? '')}`, kind: 'other', rawInput: args.query }),
270
507
  }))
271
508
 
272
509
  registerGuarded(defineTool({
@@ -280,22 +517,18 @@ export function apply(ctx: Context, config: Config): void {
280
517
  to: { type: 'string', required: true, description: 'IATA, e.g. HKT' },
281
518
  },
282
519
  output: {
283
- schema: {
284
- type: 'object',
285
- additionalProperties: false,
286
- properties: {
287
- connected: { type: 'boolean' },
288
- airlines: { type: 'array', items: { type: 'string' } },
289
- evidence: { type: 'string' },
290
- },
291
- },
292
- render: (_args, value) => [{ type: 'text', text: String((value as { evidence?: string }).evidence ?? '') }],
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)) }],
293
524
  },
294
- async execute(args: { from: string; to: string }, _exec: unknown) {
295
- const verdict = await checkConnectivity(args.from, args.to)
296
- return JSON.parse(JSON.stringify(verdict)) as Record<string, never>
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>
297
530
  },
298
- presentCall: args => ({ card: 'generic', title: `骨架校验:${args.from}-${args.to}`, kind: 'other', rawInput: args }),
531
+ presentCall: args => ({ card: 'generic', title: `骨架校验:${args.from}-${args.to}`, kind: 'execute', rawInput: args }),
299
532
  }))
300
533
 
301
534
  registerGuarded(defineTool({
@@ -309,7 +542,7 @@ export function apply(ctx: Context, config: Config): void {
309
542
  query: {
310
543
  type: 'json',
311
544
  required: true,
312
- description: '{ place: "大理市", month?: 8, mode?: "forecast"|"climate", days?: 7 }',
545
+ description: '{ place: "<城市名>", month?: 8, mode?: "forecast"|"climate", days?: 7 }',
313
546
  },
314
547
  },
315
548
  output: {
@@ -317,7 +550,7 @@ export function apply(ctx: Context, config: Config): void {
317
550
  render: (_args, value) => [{ type: 'text', text: String((value as { summary?: string }).summary ?? JSON.stringify(value).slice(0, 600)) }],
318
551
  },
319
552
  async execute(args: { query: unknown }, _exec: unknown) {
320
- const q = (args.query ?? {}) as { place?: string; lat?: number; lng?: number; month?: number; mode?: string; days?: number }
553
+ const q = unwrapQuery<{ place?: string; lat?: number; lng?: number; month?: number; mode?: string; days?: number }>(args, 'place')
321
554
  const started = Date.now()
322
555
  let lat: number | undefined = q.lat, lng: number | undefined = q.lng
323
556
  let placeLabel = q.place ?? `${q.lat},${q.lng}`
@@ -348,7 +581,17 @@ export function apply(ctx: Context, config: Config): void {
348
581
  latency_ms: Date.now() - started,
349
582
  })) as Record<string, never>
350
583
  },
351
- presentCall: args => ({ card: 'generic', title: `天气:${String((args.query as { place?: string })?.place ?? '')}`, kind: 'other', rawInput: args.query }),
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
+ },
352
595
  }))
353
596
 
354
597
  registerGuarded(defineTool({
@@ -374,7 +617,7 @@ export function apply(ctx: Context, config: Config): void {
374
617
  render: (_args, value) => [{ type: 'text', text: String((value as { summary?: string }).summary ?? JSON.stringify(value).slice(0, 500)) }],
375
618
  },
376
619
  async execute(args: { query: unknown }, _exec: unknown) {
377
- const q = (args.query ?? {}) as { callsign: string; airport?: string; timeoutMs?: number }
620
+ const q = unwrapQuery<{ callsign: string; airport?: string; timeoutMs?: number }>(args, 'callsign')
378
621
  const started = Date.now()
379
622
  if (!q.callsign) {
380
623
  return JSON.parse(JSON.stringify({ verdict: 'unavailable', evidence: '[校验不可用:无 callsign]', summary: 'callsign 必填' })) as Record<string, never>
@@ -388,18 +631,85 @@ export function apply(ctx: Context, config: Config): void {
388
631
  ? `${r.callsign} 当前观测列表未见(ADS-B 覆盖有限,不否定该航班存在)\n${r.evidence}`
389
632
  : `${r.callsign} OpenSky 不可用:${r.error}\n${r.evidence}`
390
633
  return JSON.parse(JSON.stringify({
634
+ ok: true,
391
635
  verdict: r.verdict, callsign: r.callsign, airport: r.airport,
392
636
  sample_size: r.sampleSize, hits: r.hits, evidence: r.evidence, summary,
393
637
  latency_ms: Date.now() - started,
394
638
  })) as Record<string, never>
395
639
  },
396
- presentCall: args => ({ card: 'generic', title: `飞行校验:${String((args.query as { callsign?: string })?.callsign ?? '')}`, kind: 'other', rawInput: args.query }),
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
+ },
397
707
  }))
398
708
 
399
709
  registerGuarded(defineTool({
400
710
  name: 'gotry_anything_search',
401
711
  description:
402
- 'Universal Anything search via hotel-byte CLI → hotel-be Anything endpoint. ' +
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. ' +
403
713
  'Mixed destinations (cities / metropolitan areas / high-level regions) + hotels in one call. ' +
404
714
  'Returns candidates with type, name, optional coordinates and hotel-id. ' +
405
715
  'Three-valued semantics: hit = ≥1 candidate; miss = 0 candidates (try synonyms or contentType=city/hotel); ' +
@@ -410,7 +720,7 @@ export function apply(ctx: Context, config: Config): void {
410
720
  query: {
411
721
  type: 'json',
412
722
  required: true,
413
- description: '{ keyword: "大理", contentType?: "city"|"hotel", parentDestinationId?: "?", timeoutMs?: 12000 }',
723
+ description: '{ keyword: "<搜索关键词>", contentType?: "city"|"hotel", parentDestinationId?: "?", timeoutMs?: 12000 }',
414
724
  },
415
725
  },
416
726
  output: {
@@ -418,7 +728,7 @@ export function apply(ctx: Context, config: Config): void {
418
728
  render: (_args, value) => [{ type: 'text', text: String((value as { summary?: string }).summary ?? JSON.stringify(value).slice(0, 800)) }],
419
729
  },
420
730
  async execute(args: { query: unknown }, _exec: unknown) {
421
- const q = (args.query ?? {}) as { keyword: string; contentType?: 'city' | 'hotel'; parentDestinationId?: string | number; timeoutMs?: number }
731
+ const q = unwrapQuery<{ keyword: string; contentType?: 'city' | 'hotel'; parentDestinationId?: string | number; timeoutMs?: number }>(args, 'keyword')
422
732
  const started = Date.now()
423
733
  if (!q.keyword) {
424
734
  return JSON.parse(JSON.stringify({ ok: false, verdict: 'error', summary: 'keyword 必填', evidence: '[hbcli-anything@error] empty' })) as Record<string, never>
@@ -439,7 +749,16 @@ export function apply(ctx: Context, config: Config): void {
439
749
  latency_ms: Date.now() - started,
440
750
  })) as Record<string, never>
441
751
  },
442
- presentCall: args => ({ card: 'generic', title: `Anything search:${String((args.query as { keyword?: string })?.keyword ?? '')}`, kind: 'other', rawInput: args.query }),
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
+ },
443
762
  }))
444
763
 
445
764
  registerGuarded(defineTool({
@@ -462,7 +781,7 @@ export function apply(ctx: Context, config: Config): void {
462
781
  render: (_args, value) => [{ type: 'text', text: String((value as { content?: string }).content?.slice(0, 800) ?? JSON.stringify(value).slice(0, 800)) }],
463
782
  },
464
783
  async execute(args: { query: unknown }, _exec: unknown) {
465
- const q = (args.query ?? {}) as { url?: string; timeoutMs?: number }
784
+ const q = unwrapQuery<{ url?: string; timeoutMs?: number }>(args, 'url')
466
785
  const started = Date.now()
467
786
  if (!q.url) {
468
787
  return JSON.parse(JSON.stringify({ ok: false, summary: 'url 必填', evidence: '[agent-reach:error] empty url' })) as Record<string, never>
@@ -479,7 +798,7 @@ export function apply(ctx: Context, config: Config): void {
479
798
  latency_ms: Date.now() - started,
480
799
  })) as Record<string, never>
481
800
  },
482
- presentCall: args => ({ card: 'generic', title: `读网页:${String((args.query as { url?: string })?.url ?? '')}`, kind: 'other', rawInput: args.query }),
801
+ presentCall: args => ({ card: 'generic', title: `读网页:${String((args.query as { url?: string })?.url ?? '')}`, kind: 'fetch', rawInput: args.query }),
483
802
  }))
484
803
 
485
804
  registerGuarded(defineTool({
@@ -501,7 +820,7 @@ export function apply(ctx: Context, config: Config): void {
501
820
  render: (_args, value) => [{ type: 'text', text: String((value as { summary?: string }).summary ?? JSON.stringify(value).slice(0, 600)) }],
502
821
  },
503
822
  async execute(args: { query: unknown }, _exec: unknown) {
504
- const q = (args.query ?? {}) as { url?: string; lang?: string }
823
+ const q = unwrapQuery<{ url?: string; lang?: string }>(args, 'url')
505
824
  if (!q.url) {
506
825
  return JSON.parse(JSON.stringify({ ok: false, summary: 'url 必填' })) as Record<string, never>
507
826
  }
@@ -517,7 +836,7 @@ export function apply(ctx: Context, config: Config): void {
517
836
  latency_ms: r.latencyMs,
518
837
  })) as Record<string, never>
519
838
  },
520
- presentCall: args => ({ card: 'generic', title: `视频字幕:${String((args.query as { url?: string })?.url ?? '')}`, kind: 'other', rawInput: args.query }),
839
+ presentCall: args => ({ card: 'generic', title: `视频字幕:${String((args.query as { url?: string })?.url ?? '')}`, kind: 'fetch', rawInput: args.query }),
521
840
  }))
522
841
 
523
842
  registerGuarded(defineTool({
@@ -539,7 +858,7 @@ export function apply(ctx: Context, config: Config): void {
539
858
  render: (_args, value) => [{ type: 'text', text: String((value as { summary?: string }).summary ?? JSON.stringify(value).slice(0, 600)) }],
540
859
  },
541
860
  async execute(args: { query: unknown }, _exec: unknown) {
542
- const q = (args.query ?? {}) as { query?: string; limit?: number }
861
+ const q = unwrapQuery<{ query?: string; limit?: number }>(args, 'query')
543
862
  if (!q.query) {
544
863
  return JSON.parse(JSON.stringify({ ok: false, summary: 'query 必填' })) as Record<string, never>
545
864
  }
@@ -555,18 +874,19 @@ export function apply(ctx: Context, config: Config): void {
555
874
  latency_ms: r.latencyMs,
556
875
  })) as Record<string, never>
557
876
  },
558
- presentCall: args => ({ card: 'generic', title: `GitHub 搜索:${String((args.query as { query?: string })?.query ?? '')}`, kind: 'other', rawInput: args.query }),
877
+ presentCall: args => ({ card: 'generic', title: `GitHub 搜索:${String((args.query as { query?: string })?.query ?? '')}`, kind: 'search', rawInput: args.query }),
559
878
  }))
560
879
 
561
880
  registerGuarded(defineTool({
562
881
  name: 'gotry_agent_reach',
563
882
  description:
564
- 'Agent Reach — thin wrapper over Panniantong/Agent-Reach upstream registry (zero channel knowledge here). ' +
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. ' +
565
885
  'Call ANY upstream channel method by reflection: web.read(url) / v2ex.get_hot_topics() / v2ex.search(query) / ' +
566
886
  'xueqiu.get_stock_quote(symbol) / xueqiu.search_stock(query) / youtube.transcribe(url) / <channel>.check() ... ' +
567
887
  'Unknown channel or method? Just call it — the error returns the upstream inventory (channel list or method signatures) so you can self-correct. ' +
568
888
  'Action "status" runs the real `agent-reach doctor` (.venv/bin/agent-reach). ' +
569
- 'Channels needing cookies/setup return the upstream check() guidance verbatim (never blocks). ' +
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. ' +
570
890
  'Evidence chain: [agent-reach:<channel>.<method>@ts].',
571
891
  parameters: {
572
892
  query: {
@@ -580,7 +900,7 @@ export function apply(ctx: Context, config: Config): void {
580
900
  render: (_args, value) => [{ type: 'text', text: String((value as { summary?: string }).summary ?? JSON.stringify(value).slice(0, 800)) }],
581
901
  },
582
902
  async execute(args: { query: unknown }, _exec: unknown) {
583
- const q = (args.query ?? {}) as { action?: string; channel?: string; method?: string; args?: string; timeoutMs?: number }
903
+ const q = unwrapQuery<{ action?: string; channel?: string; method?: string; args?: string; timeoutMs?: number }>(args, 'channel')
584
904
  const started = Date.now()
585
905
  const dir = await ensureStateDir(config.stateRoot)
586
906
 
@@ -600,6 +920,24 @@ export function apply(ctx: Context, config: Config): void {
600
920
  return JSON.parse(JSON.stringify({ ok: false, summary: 'channel 与 method 必填(或 action=status);清单可先随便调一次,inventory 会带回上游渠道/方法表' })) as Record<string, never>
601
921
  }
602
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)
603
941
  await recordLatency(join(dir, 'bridge-latency.jsonl'), Date.now() - started, `agent-reach:${q.channel}.${q.method}:${r.verdict}`).catch(() => {})
604
942
  const summary = r.verdict === 'found'
605
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)}`
@@ -615,6 +953,16 @@ export function apply(ctx: Context, config: Config): void {
615
953
  latency_ms: Date.now() - started,
616
954
  })) as Record<string, never>
617
955
  },
618
- 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: 'other', rawInput: args.query }),
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
+ },
619
967
  }))
620
968
  }