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

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 (44) hide show
  1. package/README.md +27 -74
  2. package/bin/gotry-inner.js +50 -1
  3. package/bin/gotry-stdio-ask.js +59 -0
  4. package/cordis.gotry-patch.yml +56 -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/hbcli.js +11 -6
  12. package/dist/capabilities/incident-log.js +2 -1
  13. package/dist/scripts/agent-reach-tests.js +1 -1
  14. package/dist/scripts/agent-reach-wrapper-tests.js +1 -1
  15. package/dist/scripts/hbcli-tests.js +18 -2
  16. package/dist/scripts/memory-capture-tests.js +77 -0
  17. package/dist/scripts/memory-metrics.js +38 -0
  18. package/dist/scripts/nudge-digest.js +93 -0
  19. package/dist/scripts/probe-poi-tests.js +9 -0
  20. package/dist/scripts/replay.js +73 -1
  21. package/dist/scripts/skeleton-check.js +3 -1
  22. package/dist/scripts/skills-contract-tests.js +85 -0
  23. package/dist/scripts/smoke.js +201 -3
  24. package/dist/scripts/time-eval-tests.js +318 -0
  25. package/dist/src/dsh-llm.js +27 -7
  26. package/dist/src/index.js +342 -92
  27. package/dist/src/loop.js +37 -10
  28. package/dist/src/memory-capture.js +40 -0
  29. package/dist/src/memory-utility.js +55 -0
  30. package/dist/src/mock-llm.js +6 -1
  31. package/dist/src/slot-spec.js +165 -0
  32. package/dist/src/time-anchor.js +100 -0
  33. package/dist/src/tool-packet.js +12 -0
  34. package/dist/src/travel-slots.js +144 -0
  35. package/dist/src/wish-pool.js +27 -0
  36. package/package.json +6 -2
  37. package/ts/capabilities/hbcli.ts +6 -6
  38. package/ts/capabilities/incident-log.ts +6 -3
  39. package/ts/scripts/skeleton-check.ts +5 -1
  40. package/ts/src/dsh-llm.ts +27 -7
  41. package/ts/src/index.ts +292 -77
  42. package/ts/src/loop.ts +52 -17
  43. package/ts/src/mock-llm.ts +13 -1
  44. package/ts/cordis.gotry-patch.yml +0 -36
package/ts/src/index.ts CHANGED
@@ -14,6 +14,8 @@
14
14
  */
15
15
 
16
16
  import { join } from 'node:path'
17
+ import { readFile, writeFile } from 'node:fs/promises'
18
+ import { readFileSync } from 'node:fs'
17
19
  import type { Context } from '@deepseek-ai/cordis'
18
20
  import z from '@deepseek-ai/schemastery'
19
21
  import { defineTool } from '@deepseek-ai/dsh-tools'
@@ -23,6 +25,12 @@ import { checkConnectivity } from '../scripts/skeleton-check.ts'
23
25
  import { parseCandidate, parseRequest } from './model.ts'
24
26
  import { searchHotels as hbcliSearchHotels } from '../capabilities/hbcli.ts'
25
27
  import { installProcessGuards, guardToolExecute } from '../capabilities/incident-log.ts'
28
+ import { interpretArgs, type GotryObservation } from './tool-packet.ts'
29
+ import { appendEvent, projectUtility, type MemoryUtilityEvent } from './memory-utility.ts'
30
+ import { pickNudgeWish, type WishPoolEntry } from './wish-pool.ts'
31
+ import { mergeProfile } from './memory-capture.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'
@@ -49,6 +57,8 @@ export const Config: z<Config> = z.object({
49
57
 
50
58
  interface FeasibilityResult {
51
59
  answer_md?: string
60
+ recommended?: string | null
61
+ verdicts?: Array<Record<string, unknown>>
52
62
  }
53
63
 
54
64
  interface MotivationProfileInput {
@@ -63,6 +73,34 @@ interface WishPoolEntryInput {
63
73
  conditions?: Record<string, unknown>
64
74
  }
65
75
 
76
+
77
+ /**
78
+ * query 包装参数的三形态归一(#12/#13):实现移居 tool-packet.ts(interpretArgs,
79
+ * RFC S1 interpretation 层语义归位),此处按旧名引 handy 别名,调用点零漂移。
80
+ */
81
+ const unwrapQuery = interpretArgs
82
+
83
+ /**
84
+ * 动机画像 → persona 紧凑 brief(M4 T1 读回路径):空文件返回 ''(首访),
85
+ * persona 据此决定是否访谈。只读,不猜——画像里没有的字段不编。
86
+ */
87
+ function renderMotivationBrief(stateRoot: string): string {
88
+ try {
89
+ const raw = readFileSync(join(stateRoot, 'gotry-state', 'motivation-profile.json'), 'utf-8')
90
+ const p = JSON.parse(raw) as { weights?: Record<string, number>; evidence?: string[]; hard?: Record<string, unknown>; updated_at?: string }
91
+ const lines: string[] = ['## 用户记忆(跨会话画像;与用户当轮说法冲突时以用户为准,更新经 gotry_motivation_save)']
92
+ const weights = Object.entries(p.weights ?? {})
93
+ if (weights.length) lines.push(`- 动机权重: ${weights.map(([k, v]) => `${k}=${v}`).join(', ')}(证据 ${p.evidence?.length ?? 0} 条)`)
94
+ const hard = Object.entries(p.hard ?? {})
95
+ if (hard.length) lines.push(`- 硬约束: ${hard.map(([k, v]) => `${k}=${String(v)}`).join(', ')}`)
96
+ lines.push(`- 愿望池: 用 gotry_wish_pool_list 按条件召回(0..1),勿直接堆砌`)
97
+ if (p.updated_at) lines.push(`- 更新于: ${p.updated_at}`)
98
+ return weights.length || hard.length ? lines.join('\n') : ''
99
+ } catch {
100
+ return '' // 首访:无画像
101
+ }
102
+ }
103
+
66
104
  type Json = string | number | boolean | null | Json[] | { [k: string]: Json }
67
105
  type JsonObject = { [k: string]: Json }
68
106
 
@@ -78,6 +116,14 @@ export function apply(ctx: Context, config: Config): void {
78
116
  const weekdays = ['日', '一', '二', '三', '四', '五', '六']
79
117
  return `${ymd} 周${weekdays[d.getDay()]}`
80
118
  })
119
+ // 时间锚点卡(time-anchor 层,确定性):相对日期换算的唯一依据——
120
+ // 明天/下周X/下个月中旬/节日都查卡,LLM 不自算(算术只在代码层)。
121
+ sp?.variable?.('time_anchor_card', () => buildTimeAnchor(new Date()).card)
122
+
123
+ // M4 记忆读回(T1 闭环的另一半):motivation-profile 此前只写不读,每个新会话
124
+ // 模型都是盲的、重新访谈——「回访规划时长降 ≥50%」不可能成立。这里把画像
125
+ // 渲染成紧凑 brief 注入 persona;为空 = 首访。与当轮说法冲突时以用户为准。
126
+ sp?.variable?.('motivation_brief', () => renderMotivationBrief(config.stateRoot ?? '.'))
81
127
 
82
128
  // D-NEW 进程护栏(Z3 WASM crash 教训):dsh 0.1.1-rc.1 缺 uncaughtException
83
129
  // handler,插件异常穿透即杀进程。我们在 gotry 侧挂护栏:同步 fsync 写事故证据
@@ -101,7 +147,8 @@ export function apply(ctx: Context, config: Config): void {
101
147
  'Check travel candidates against the user\'s motivation and hard constraints using the '
102
148
  + 'door-to-door true-cost engine (wake time, arrival state, energy, usable hours, money). '
103
149
  + '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. '
150
+ + 'and the candidate list (services/transfers/stay costs/min days): '
151
+ + 'structure { request: { motivation weights, hard constraints, window, budget, home hubs }, candidates: [ { id, label, services, transfers, stay, minDays } ] }. '
105
152
  + 'Returns per-candidate verdicts, unsat cores with minimal-modification suggestions, '
106
153
  + 'a wish-pool entry for infeasible aspirations, and a ready-to-show markdown answer.',
107
154
  parameters: {
@@ -129,9 +176,28 @@ export function apply(ctx: Context, config: Config): void {
129
176
  const result = solveChoiceSegment(spec, req) as Record<string, unknown>
130
177
  const dir = await ensureStateDir(config.stateRoot)
131
178
  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' }
179
+ return { ok: true, ...result, latency_ms: Date.now() - started, via: 'in-process-unified' }
180
+ },
181
+ presentCall: args => ({ card: 'generic', title: 'GoTry 可行性检查(门到门全成本)', kind: 'execute', rawInput: args.payload }),
182
+ presentResult: (args, value) => {
183
+ // D-4 卡片赎回:结果卡不再是裸 JSON —— 逐候选判定 + 全成本 vs 预算紧凑行 + 人话答案
184
+ const r = value as FeasibilityResult
185
+ const budget = ((args.payload as { request?: { budget_cny?: number } })?.request)?.budget_cny
186
+ const lines = (r.verdicts ?? []).map(v => {
187
+ const tc = (v.true_cost ?? {}) as { money_cny?: number }
188
+ const money = typeof tc.money_cny === 'number'
189
+ ? ` ¥${tc.money_cny}/人` + (typeof budget === 'number' ? `(预算 ¥${budget},${tc.money_cny <= budget ? '余' : '超'} ¥${Math.abs(budget - tc.money_cny)})` : '')
190
+ : ''
191
+ return v.feasible
192
+ ? `✅ ${String(v.name ?? v.candidate_id)}${money}${v.candidate_id === r.recommended ? ' ← 推荐' : ''}`
193
+ : `❌ ${String(v.name ?? v.candidate_id)} — ${(Array.isArray(v.unsat_core) ? v.unsat_core.join(',') : '不可行')}`
194
+ })
195
+ return {
196
+ card: 'generic',
197
+ title: `可行性:${r.recommended ? `推荐 ${r.recommended}` : '全部不可行'}`,
198
+ content: [{ type: 'text', text: (lines.length ? lines.join('\n') + '\n\n' : '') + String(r.answer_md ?? '').slice(0, 1500) }],
199
+ }
133
200
  },
134
- presentCall: args => ({ card: 'generic', title: 'GoTry 可行性检查(门到门全成本)', kind: 'other', rawInput: args.payload }),
135
201
  }))
136
202
 
137
203
  registerGuarded(defineTool({
@@ -139,8 +205,9 @@ export function apply(ctx: Context, config: Config): void {
139
205
  description:
140
206
  'Persist the traveler\'s motivation profile (the "why depart" contract object). '
141
207
  + '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).',
208
+ + 'never the principal/sponsor distinction. MERGE semantics (T1): call again with just the NEW '
209
+ + 'facts learned this turn (weights delta optional but MUST bring fresh evidence; evidence = user quotes); '
210
+ + 'existing history is never deleted. Requires evidence on every call (P0 anti-fabrication rule).',
144
211
  parameters: {
145
212
  profile: {
146
213
  type: 'json',
@@ -149,30 +216,35 @@ export function apply(ctx: Context, config: Config): void {
149
216
  },
150
217
  },
151
218
  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
- },
219
+ // loose json:guard 兜底字段(ok/summary)不被 strict schema 拒(骨架事故同款)
220
+ schema: { type: 'json' },
161
221
  render: (_args, value) => [{ type: 'text', text: `动机画像已保存:${String((value as { path?: string }).path ?? '')}` }],
162
222
  },
163
223
  async execute(args: { profile: unknown }, _exec: unknown) {
164
- const profile = (args.profile ?? {}) as MotivationProfileInput
165
- if (!profile.evidence?.length) {
224
+ // T1 接线:本工具是增量补丁语义(契约 18——模型每轮把对话新事实带进来),
225
+ // 经 mergeProfile 守门合并进既有画像(追加不删史/幂等/权重变更须伴新证据),
226
+ // 不再整档覆盖。首次调用(无档案)= 全量建立。
227
+ const incoming = (args.profile ?? {}) as { weights?: Record<string, number>; evidence?: string[]; hard?: Record<string, unknown> }
228
+ if (!incoming.evidence?.length) {
166
229
  throw new Error('refusing to save a motivation profile without evidence (P0 anti-fabrication rule)')
167
230
  }
168
231
  const dir = await ensureStateDir(config.stateRoot)
169
232
  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
233
+ let existing: { weights?: Record<string, number>; evidence?: string[]; hard?: Record<string, unknown> } | null = null
234
+ try {
235
+ existing = await readJson<typeof existing>(path, null)
236
+ } catch { /* 首次保存:无档案 */ }
237
+ const merged = mergeProfile(existing, { weights: incoming.weights, evidence: incoming.evidence, hard: incoming.hard })
238
+ if (!merged) {
239
+ // 幂等:补丁与现有画像完全一致,不落盘
240
+ const currentJson = JSON.parse(JSON.stringify({ ...(existing ?? {}), updated_at: new Date().toISOString() })) as JsonObject
241
+ return { ok: true, saved: false, path, profile: currentJson, summary: '无新内容(幂等跳过)' }
242
+ }
243
+ const saved = JSON.parse(JSON.stringify({ ...merged, updated_at: new Date().toISOString() })) as JsonObject
172
244
  await writeJson(path, saved)
173
- return { saved: true, path, profile: saved }
245
+ return { ok: true, saved: true, path, profile: saved, summary: '画像已合并落盘' }
174
246
  },
175
- presentCall: args => ({ card: 'generic', title: '保存动机画像', kind: 'other', rawInput: args.profile }),
247
+ presentCall: args => ({ card: 'generic', title: '保存动机画像', kind: 'edit', rawInput: args.profile }),
176
248
  }))
177
249
 
178
250
  registerGuarded(defineTool({
@@ -180,62 +252,135 @@ export function apply(ctx: Context, config: Config): void {
180
252
  description:
181
253
  'Add an aspiration to the "next departure" wish pool — the graceful home for infeasible dreams. '
182
254
  + 'An entry carries its fulfilment conditions (days needed, budget, best months) so a future '
183
- + '"next departure" nudge can fire when the window matches. 憧憬不被拒绝。',
255
+ + '"next departure" nudge can fire when the window matches. Each entry gets a stable wish_id '
256
+ + '(the memory-utility sidecar keys on it); muted:true puts a wish dormant (永不删除,只是不再召回). 憧憬不被拒绝。',
184
257
  parameters: {
185
258
  entry: {
186
259
  type: 'json',
187
260
  required: true,
188
- description: '{ name, reason, conditions: { days, budget_cny, best_months } }',
261
+ description: '{ name, reason?, conditions: { days, budget_cny, best_months }, muted?: boolean }',
189
262
  },
190
263
  },
191
264
  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
- },
265
+ // loose json:guard 兜底字段(ok/summary)不被 strict schema 拒(骨架事故同款)
266
+ schema: { type: 'json' },
201
267
  render: (_args, value) => {
202
268
  const v = value as { total?: number; path?: string }
203
269
  return [{ type: 'text', text: `已加入「下一次出发」清单(共 ${v.total ?? '?'} 项):${v.path ?? ''}` }]
204
270
  },
205
271
  },
206
272
  async execute(args: { entry: unknown }, _exec: unknown) {
207
- const entry = (args.entry ?? {}) as WishPoolEntryInput
273
+ const entry = (args.entry ?? {}) as WishPoolEntryInput & { muted?: boolean }
208
274
  if (!entry.name || !entry.conditions) {
209
275
  throw new Error('wish pool entry requires name and conditions (fulfilment conditions are the whole point)')
210
276
  }
211
277
  const dir = await ensureStateDir(config.stateRoot)
212
278
  const path = join(dir, 'wish-pool.json')
213
279
  const pool = (await readJson(path, [])) as Array<Record<string, unknown>>
214
- // 同名憧憬幂等更新(刷新理由与成行条件),不重复落盘
280
+ // 同名憧憬幂等更新(刷新理由与成行条件),不重复落盘;wish_id 稳定(存量补签)
215
281
  const existing = pool.findIndex(e => e['name'] === entry.name)
216
282
  if (existing >= 0) {
217
- pool[existing] = { ...pool[existing], reason: entry.reason ?? pool[existing]?.['reason'], conditions: entry.conditions }
283
+ const prev = pool[existing] ?? {}
284
+ pool[existing] = {
285
+ ...prev,
286
+ wish_id: prev['wish_id'] ?? `w${Date.now().toString(36)}`,
287
+ reason: entry.reason ?? prev['reason'],
288
+ conditions: entry.conditions,
289
+ ...(entry.muted !== undefined ? { muted: entry.muted } : {}),
290
+ }
218
291
  await writeJson(path, pool)
219
- return { added: false, total: pool.length, path }
292
+ return { ok: true, added: false, wish_id: String(pool[existing]?.['wish_id']), total: pool.length, path }
220
293
  }
221
- pool.push({ reason: '', ...entry, added_at: new Date().toISOString() })
294
+ const created = { wish_id: `w${Date.now().toString(36)}`, reason: '', ...entry, added_at: new Date().toISOString() }
295
+ pool.push(created)
222
296
  await writeJson(path, pool)
223
- return { added: true, total: pool.length, path }
297
+ return { ok: true, added: true, wish_id: created.wish_id, total: pool.length, path }
224
298
  },
225
- presentCall: args => ({ card: 'generic', title: '加入「下一次出发」清单', kind: 'other', rawInput: args.entry }),
299
+ presentCall: args => ({ card: 'generic', title: '加入「下一次出发」清单', kind: 'edit', rawInput: args.entry }),
300
+ }))
301
+
302
+ registerGuarded(defineTool({
303
+ name: 'gotry_wish_pool_list',
304
+ description:
305
+ 'Surface AT MOST ONE "next departure" wish whose fulfilment conditions match the user\'s current window '
306
+ + '(0..1 rule: never more than one nudge per turn, never push when nothing matches — 憧憬不被拒绝,也不被硬推). '
307
+ + 'Muted wishes never surface. Surfacing records a recalled event in the memory-utility sidecar. '
308
+ + 'action="confirm-outcome" records the user-confirmed real-world outcome (attribution helpful/harmful/neutral) — '
309
+ + 'ONLY pass attribution the user explicitly stated; the agent must never self-attribute usefulness.',
310
+ parameters: {
311
+ query: {
312
+ type: 'json',
313
+ required: true,
314
+ description: '{ action?: "recall"|"confirm-outcome", days?: number, budgetCny?: number, month?: number, wishId?: string, attribution?: "helpful"|"harmful"|"neutral", detail?: string }',
315
+ },
316
+ },
317
+ output: {
318
+ schema: { type: 'json' },
319
+ render: (_args, value) => [{ type: 'text', text: String((value as { summary?: string }).summary ?? '') }],
320
+ },
321
+ async execute(args: { query: unknown }, _exec: unknown) {
322
+ const q = unwrapQuery<{ action?: string; days?: number; budgetCny?: number; month?: number; wishId?: string; attribution?: 'helpful' | 'harmful' | 'neutral'; detail?: string }>(args, 'action')
323
+ const dir = await ensureStateDir(config.stateRoot)
324
+ const pool = (await readJson(join(dir, 'wish-pool.json'), [])) as Array<Record<string, unknown>>
325
+ const sidecarPath = join(dir, 'memory-utility.jsonl')
326
+ const loadSidecar = async (): Promise<MemoryUtilityEvent[]> => {
327
+ try {
328
+ const raw = await readFile(sidecarPath, 'utf-8')
329
+ return raw.split('\n').filter(Boolean).map(l => JSON.parse(l) as MemoryUtilityEvent)
330
+ } catch { return [] } // fail-open:sidecar 缺失/损坏不阻塞召回
331
+ }
332
+ const saveSidecar = async (events: MemoryUtilityEvent[]) => {
333
+ await writeFile(sidecarPath, events.map(e => JSON.stringify(e)).join('\n') + '\n', 'utf-8')
334
+ }
335
+ const now = new Date().toISOString()
336
+ if (q.action === 'confirm-outcome') {
337
+ if (!q.wishId || !q.attribution) {
338
+ return { ok: false, summary: 'confirm-outcome 需要 wishId + attribution(helpful|harmful|neutral)' } as never
339
+ }
340
+ const events = await loadSidecar()
341
+ const { events: next, appended } = appendEvent(events, {
342
+ wish_id: q.wishId, kind: 'verified_outcome', ts: now,
343
+ ctx: 'gotry_wish_pool_list.confirm', detail: q.detail, attribution: q.attribution,
344
+ })
345
+ if (appended) await saveSidecar(next)
346
+ return { ok: true, recorded: appended, wish_id: q.wishId, status: q.attribution } as never
347
+ }
348
+ // recall:0..1 条件匹配(判定归 wish-pool 纯函数),muted 永不召回,无命中不硬推
349
+ const candidates = pool.filter(e => !e['muted'] && typeof e['wish_id'] === 'string')
350
+ const month = q.month ?? new Date().getMonth() + 1
351
+ const match = pickNudgeWish(candidates as WishPoolEntry[], { days: q.days, budgetCny: q.budgetCny, month })
352
+ if (!match) {
353
+ return { ok: true, suggestion: null, summary: `无可成行的憧憬匹配当前窗口(${candidates.length} 条在册,0..1 纪律:不硬推)` } as never
354
+ }
355
+ const events = await loadSidecar()
356
+ const { events: next, appended } = appendEvent(events, {
357
+ wish_id: match.wishId, kind: 'recalled', ts: now, ctx: 'gotry_wish_pool_list.recall',
358
+ })
359
+ if (appended) await saveSidecar(next)
360
+ const utility = projectUtility(next)[match.wishId]
361
+ return {
362
+ ok: true,
363
+ 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 },
364
+ utility: { status: utility?.status ?? 'unknown', recalled: utility?.recalled ?? 1 },
365
+ summary: `「下一次出发」候选(0..1):${String(match.entry['name'])}——成行条件 ${JSON.stringify(match.entry['conditions'])},本次窗口命中 ${match.score}/3 项(${match.hits.join('+')});效用状态 ${utility?.status ?? 'unknown'}`,
366
+ } as never
367
+ },
368
+ presentCall: args => ({ card: 'generic', title: '「下一次出发」召回', kind: 'search', rawInput: args.query }),
226
369
  }))
227
370
 
228
371
  registerGuarded(defineTool({
229
372
  name: 'gotry_hotel_search',
230
373
  description:
231
374
  '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, '
375
+ + 'Input: destination city name + optional dates/occupancy. Dates accept verbatim natural expressions (下周五 / 8.20 / 下周五+3) — '
376
+ + 'the code layer resolves them against the time anchor; unresolved expressions degrade to an undated search with an explicit '
377
+ + 'date_notes entry instead of guessing. Output: hotel list with evidence chain ([realtime-API:hbcli] + fetch timestamp, '
233
378
  + 'or [static-pack:estimate]) per the L4 invariant.',
234
379
  parameters: {
235
380
  query: {
236
381
  type: 'json',
237
382
  required: true,
238
- description: '{ destination: "普吉", checkIn?: "2026-07-18", checkOut?: "2026-07-23", occupancy?: { adults: 2 } }',
383
+ description: '{ destination: "<目的地城市>", checkIn?: "YYYY-MM-DD 或自然表达(下周五/8.20)", checkOut?: 同上, occupancy?: { adults: 2 } }',
239
384
  },
240
385
  },
241
386
  output: {
@@ -243,12 +388,26 @@ export function apply(ctx: Context, config: Config): void {
243
388
  render: (_args, value) => [{ type: 'text', text: String((value as { summary?: string }).summary ?? JSON.stringify(value).slice(0, 400)) }],
244
389
  },
245
390
  async execute(args: { query: unknown }, _exec: unknown) {
246
- const q = (args.query ?? {}) as { destination?: string; checkIn?: string; checkOut?: string; adults?: number }
391
+ const q = unwrapQuery<{ destination?: string; checkIn?: string; checkOut?: string; adults?: number }>(args, 'destination')
247
392
  if (!q.destination) throw new Error('gotry_hotel_search requires destination')
248
393
  const started = Date.now()
249
394
  const fallbackPath = join(import.meta.dirname, '..', '..', 'data', 'hotels_2026.json')
395
+ // D-10 切片 B:日期槽位接受逐字自然表达(下周五/8.20/+N),代码层换算(slot-spec);
396
+ // unresolved 不猜——降级为无日期搜索并显式记 note,由模型向用户追问
397
+ const anchor = buildTimeAnchor(new Date())
398
+ const dateNotes: string[] = []
399
+ const resolveDate = (expr?: string): string | undefined => {
400
+ if (!expr) return undefined
401
+ const r = resolveSlotDate(expr, anchor)
402
+ if (!r.date) {
403
+ dateNotes.push(`日期未解析:${r.raw}——请向用户确认具体日期`)
404
+ return undefined
405
+ }
406
+ if (r.raw !== r.date) dateNotes.push(`slot-resolved: ${r.raw} → ${r.date}`)
407
+ return r.date
408
+ }
250
409
  const resp = await hbcliSearchHotels(
251
- { destination: q.destination, checkIn: q.checkIn, checkOut: q.checkOut, adults: q.adults },
410
+ { destination: q.destination, checkIn: resolveDate(q.checkIn), checkOut: resolveDate(q.checkOut), adults: q.adults },
252
411
  { hbcliBin: config.hbcliBin, timeoutMs: config.timeoutMs, fallbackPath },
253
412
  )
254
413
  const dir = await ensureStateDir(config.stateRoot)
@@ -256,6 +415,7 @@ export function apply(ctx: Context, config: Config): void {
256
415
  const evidence = isLive ? resp.evidence : '[静态包:估算]'
257
416
  await recordLatency(join(dir, 'bridge-latency.jsonl'), Date.now() - started, `hotel_search:${resp.via}`).catch(() => {})
258
417
  const payload = {
418
+ ok: true,
259
419
  hotels: resp.hotels ?? null,
260
420
  evidence,
261
421
  destination: q.destination,
@@ -263,10 +423,20 @@ export function apply(ctx: Context, config: Config): void {
263
423
  latency_ms: Date.now() - started,
264
424
  summary: resp.summary,
265
425
  error: resp.error,
426
+ ...(dateNotes.length ? { date_notes: dateNotes } : {}),
427
+ } as never
428
+ return JSON.parse(JSON.stringify(payload)) as never
429
+ },
430
+ presentCall: args => ({ card: 'generic', title: `酒店搜索:${String((args.query as { destination?: string })?.destination ?? '')}`, kind: 'search', rawInput: args.query }),
431
+ presentResult: (_args, value) => {
432
+ const r = value as { hotels?: unknown[]; via?: string; destination?: string; summary?: string }
433
+ const n = Array.isArray(r.hotels) ? r.hotels.length : 0
434
+ return {
435
+ card: 'generic',
436
+ title: `酒店:${r.destination ?? ''} ${n ? `${n} 家(${r.via === 'hbcli-realtime' ? '实时' : '静态包'})` : '无结果'}`,
437
+ content: [{ type: 'text', text: String(r.summary ?? '') }],
266
438
  }
267
- return JSON.parse(JSON.stringify(payload)) as Record<string, unknown>
268
439
  },
269
- presentCall: args => ({ card: 'generic', title: `酒店搜索:${String((args.query as { destination?: string })?.destination ?? '')}`, kind: 'other', rawInput: args.query }),
270
440
  }))
271
441
 
272
442
  registerGuarded(defineTool({
@@ -280,22 +450,18 @@ export function apply(ctx: Context, config: Config): void {
280
450
  to: { type: 'string', required: true, description: 'IATA, e.g. HKT' },
281
451
  },
282
452
  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 ?? '') }],
453
+ // loose json(与兄弟工具一致):strict additionalProperties:false 会拒 guard 的
454
+ // 错误兜底字段(ok/summary),校验错掩盖真实错误
455
+ schema: { type: 'json' },
456
+ render: (_args, value) => [{ type: 'text', text: String((value as { evidence?: string }).evidence ?? JSON.stringify(value)) }],
293
457
  },
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>
458
+ async execute(args: { from?: string; to?: string; query?: unknown } & Record<string, unknown>, _exec: unknown) {
459
+ // 平铺参数的工具也会被 LLM 包进 query(#12/#13 同款形态),unwrapQuery 兜住
460
+ const q = unwrapQuery<{ from?: string; to?: string }>(args)
461
+ const verdict = await checkConnectivity(String(q.from ?? ''), String(q.to ?? ''))
462
+ return JSON.parse(JSON.stringify({ ok: true, ...verdict })) as Record<string, never>
297
463
  },
298
- presentCall: args => ({ card: 'generic', title: `骨架校验:${args.from}-${args.to}`, kind: 'other', rawInput: args }),
464
+ presentCall: args => ({ card: 'generic', title: `骨架校验:${args.from}-${args.to}`, kind: 'execute', rawInput: args }),
299
465
  }))
300
466
 
301
467
  registerGuarded(defineTool({
@@ -309,7 +475,7 @@ export function apply(ctx: Context, config: Config): void {
309
475
  query: {
310
476
  type: 'json',
311
477
  required: true,
312
- description: '{ place: "大理市", month?: 8, mode?: "forecast"|"climate", days?: 7 }',
478
+ description: '{ place: "<城市名>", month?: 8, mode?: "forecast"|"climate", days?: 7 }',
313
479
  },
314
480
  },
315
481
  output: {
@@ -317,7 +483,7 @@ export function apply(ctx: Context, config: Config): void {
317
483
  render: (_args, value) => [{ type: 'text', text: String((value as { summary?: string }).summary ?? JSON.stringify(value).slice(0, 600)) }],
318
484
  },
319
485
  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 }
486
+ const q = unwrapQuery<{ place?: string; lat?: number; lng?: number; month?: number; mode?: string; days?: number }>(args, 'place')
321
487
  const started = Date.now()
322
488
  let lat: number | undefined = q.lat, lng: number | undefined = q.lng
323
489
  let placeLabel = q.place ?? `${q.lat},${q.lng}`
@@ -348,7 +514,17 @@ export function apply(ctx: Context, config: Config): void {
348
514
  latency_ms: Date.now() - started,
349
515
  })) as Record<string, never>
350
516
  },
351
- presentCall: args => ({ card: 'generic', title: `天气:${String((args.query as { place?: string })?.place ?? '')}`, kind: 'other', rawInput: args.query }),
517
+ presentCall: args => ({ card: 'generic', title: `天气:${String((args.query as { place?: string })?.place ?? '')}`, kind: 'fetch', rawInput: args.query }),
518
+ presentResult: (args, value) => {
519
+ const r = value as { summary?: string }
520
+ const place = String((args.query as { place?: string })?.place ?? '')
521
+ const failed = String(r.summary ?? '').includes('降级') || String(r.summary ?? '').includes('unavailable')
522
+ return {
523
+ card: 'generic',
524
+ title: `天气:${place} ${failed ? '降级' : 'ok'}`,
525
+ content: [{ type: 'text', text: String(r.summary ?? '') }],
526
+ }
527
+ },
352
528
  }))
353
529
 
354
530
  registerGuarded(defineTool({
@@ -374,7 +550,7 @@ export function apply(ctx: Context, config: Config): void {
374
550
  render: (_args, value) => [{ type: 'text', text: String((value as { summary?: string }).summary ?? JSON.stringify(value).slice(0, 500)) }],
375
551
  },
376
552
  async execute(args: { query: unknown }, _exec: unknown) {
377
- const q = (args.query ?? {}) as { callsign: string; airport?: string; timeoutMs?: number }
553
+ const q = unwrapQuery<{ callsign: string; airport?: string; timeoutMs?: number }>(args, 'callsign')
378
554
  const started = Date.now()
379
555
  if (!q.callsign) {
380
556
  return JSON.parse(JSON.stringify({ verdict: 'unavailable', evidence: '[校验不可用:无 callsign]', summary: 'callsign 必填' })) as Record<string, never>
@@ -388,18 +564,19 @@ export function apply(ctx: Context, config: Config): void {
388
564
  ? `${r.callsign} 当前观测列表未见(ADS-B 覆盖有限,不否定该航班存在)\n${r.evidence}`
389
565
  : `${r.callsign} OpenSky 不可用:${r.error}\n${r.evidence}`
390
566
  return JSON.parse(JSON.stringify({
567
+ ok: true,
391
568
  verdict: r.verdict, callsign: r.callsign, airport: r.airport,
392
569
  sample_size: r.sampleSize, hits: r.hits, evidence: r.evidence, summary,
393
570
  latency_ms: Date.now() - started,
394
571
  })) as Record<string, never>
395
572
  },
396
- presentCall: args => ({ card: 'generic', title: `飞行校验:${String((args.query as { callsign?: string })?.callsign ?? '')}`, kind: 'other', rawInput: args.query }),
573
+ presentCall: args => ({ card: 'generic', title: `飞行校验:${String((args.query as { callsign?: string })?.callsign ?? '')}`, kind: 'fetch', rawInput: args.query }),
397
574
  }))
398
575
 
399
576
  registerGuarded(defineTool({
400
577
  name: 'gotry_anything_search',
401
578
  description:
402
- 'Universal Anything search via hotel-byte CLI → hotel-be Anything endpoint. ' +
579
+ '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
580
  'Mixed destinations (cities / metropolitan areas / high-level regions) + hotels in one call. ' +
404
581
  'Returns candidates with type, name, optional coordinates and hotel-id. ' +
405
582
  'Three-valued semantics: hit = ≥1 candidate; miss = 0 candidates (try synonyms or contentType=city/hotel); ' +
@@ -410,7 +587,7 @@ export function apply(ctx: Context, config: Config): void {
410
587
  query: {
411
588
  type: 'json',
412
589
  required: true,
413
- description: '{ keyword: "大理", contentType?: "city"|"hotel", parentDestinationId?: "?", timeoutMs?: 12000 }',
590
+ description: '{ keyword: "<搜索关键词>", contentType?: "city"|"hotel", parentDestinationId?: "?", timeoutMs?: 12000 }',
414
591
  },
415
592
  },
416
593
  output: {
@@ -418,7 +595,7 @@ export function apply(ctx: Context, config: Config): void {
418
595
  render: (_args, value) => [{ type: 'text', text: String((value as { summary?: string }).summary ?? JSON.stringify(value).slice(0, 800)) }],
419
596
  },
420
597
  async execute(args: { query: unknown }, _exec: unknown) {
421
- const q = (args.query ?? {}) as { keyword: string; contentType?: 'city' | 'hotel'; parentDestinationId?: string | number; timeoutMs?: number }
598
+ const q = unwrapQuery<{ keyword: string; contentType?: 'city' | 'hotel'; parentDestinationId?: string | number; timeoutMs?: number }>(args, 'keyword')
422
599
  const started = Date.now()
423
600
  if (!q.keyword) {
424
601
  return JSON.parse(JSON.stringify({ ok: false, verdict: 'error', summary: 'keyword 必填', evidence: '[hbcli-anything@error] empty' })) as Record<string, never>
@@ -439,7 +616,16 @@ export function apply(ctx: Context, config: Config): void {
439
616
  latency_ms: Date.now() - started,
440
617
  })) as Record<string, never>
441
618
  },
442
- presentCall: args => ({ card: 'generic', title: `Anything search:${String((args.query as { keyword?: string })?.keyword ?? '')}`, kind: 'other', rawInput: args.query }),
619
+ presentCall: args => ({ card: 'generic', title: `Anything search:${String((args.query as { keyword?: string })?.keyword ?? '')}`, kind: 'search', rawInput: args.query }),
620
+ presentResult: (_args, value) => {
621
+ const r = value as { hits?: unknown[]; total_candidates?: number; verdict?: string; keyword?: string; summary?: string }
622
+ const n = Array.isArray(r.hits) ? r.hits.length : (r.total_candidates ?? 0)
623
+ return {
624
+ card: 'generic',
625
+ title: `Anything:${r.keyword ?? ''} ${r.verdict === 'hit' ? `${n} hits` : (r.verdict ?? 'no-result')}`,
626
+ content: [{ type: 'text', text: String(r.summary ?? '') }],
627
+ }
628
+ },
443
629
  }))
444
630
 
445
631
  registerGuarded(defineTool({
@@ -462,7 +648,7 @@ export function apply(ctx: Context, config: Config): void {
462
648
  render: (_args, value) => [{ type: 'text', text: String((value as { content?: string }).content?.slice(0, 800) ?? JSON.stringify(value).slice(0, 800)) }],
463
649
  },
464
650
  async execute(args: { query: unknown }, _exec: unknown) {
465
- const q = (args.query ?? {}) as { url?: string; timeoutMs?: number }
651
+ const q = unwrapQuery<{ url?: string; timeoutMs?: number }>(args, 'url')
466
652
  const started = Date.now()
467
653
  if (!q.url) {
468
654
  return JSON.parse(JSON.stringify({ ok: false, summary: 'url 必填', evidence: '[agent-reach:error] empty url' })) as Record<string, never>
@@ -479,7 +665,7 @@ export function apply(ctx: Context, config: Config): void {
479
665
  latency_ms: Date.now() - started,
480
666
  })) as Record<string, never>
481
667
  },
482
- presentCall: args => ({ card: 'generic', title: `读网页:${String((args.query as { url?: string })?.url ?? '')}`, kind: 'other', rawInput: args.query }),
668
+ presentCall: args => ({ card: 'generic', title: `读网页:${String((args.query as { url?: string })?.url ?? '')}`, kind: 'fetch', rawInput: args.query }),
483
669
  }))
484
670
 
485
671
  registerGuarded(defineTool({
@@ -501,7 +687,7 @@ export function apply(ctx: Context, config: Config): void {
501
687
  render: (_args, value) => [{ type: 'text', text: String((value as { summary?: string }).summary ?? JSON.stringify(value).slice(0, 600)) }],
502
688
  },
503
689
  async execute(args: { query: unknown }, _exec: unknown) {
504
- const q = (args.query ?? {}) as { url?: string; lang?: string }
690
+ const q = unwrapQuery<{ url?: string; lang?: string }>(args, 'url')
505
691
  if (!q.url) {
506
692
  return JSON.parse(JSON.stringify({ ok: false, summary: 'url 必填' })) as Record<string, never>
507
693
  }
@@ -517,7 +703,7 @@ export function apply(ctx: Context, config: Config): void {
517
703
  latency_ms: r.latencyMs,
518
704
  })) as Record<string, never>
519
705
  },
520
- presentCall: args => ({ card: 'generic', title: `视频字幕:${String((args.query as { url?: string })?.url ?? '')}`, kind: 'other', rawInput: args.query }),
706
+ presentCall: args => ({ card: 'generic', title: `视频字幕:${String((args.query as { url?: string })?.url ?? '')}`, kind: 'fetch', rawInput: args.query }),
521
707
  }))
522
708
 
523
709
  registerGuarded(defineTool({
@@ -539,7 +725,7 @@ export function apply(ctx: Context, config: Config): void {
539
725
  render: (_args, value) => [{ type: 'text', text: String((value as { summary?: string }).summary ?? JSON.stringify(value).slice(0, 600)) }],
540
726
  },
541
727
  async execute(args: { query: unknown }, _exec: unknown) {
542
- const q = (args.query ?? {}) as { query?: string; limit?: number }
728
+ const q = unwrapQuery<{ query?: string; limit?: number }>(args, 'query')
543
729
  if (!q.query) {
544
730
  return JSON.parse(JSON.stringify({ ok: false, summary: 'query 必填' })) as Record<string, never>
545
731
  }
@@ -555,18 +741,19 @@ export function apply(ctx: Context, config: Config): void {
555
741
  latency_ms: r.latencyMs,
556
742
  })) as Record<string, never>
557
743
  },
558
- presentCall: args => ({ card: 'generic', title: `GitHub 搜索:${String((args.query as { query?: string })?.query ?? '')}`, kind: 'other', rawInput: args.query }),
744
+ presentCall: args => ({ card: 'generic', title: `GitHub 搜索:${String((args.query as { query?: string })?.query ?? '')}`, kind: 'search', rawInput: args.query }),
559
745
  }))
560
746
 
561
747
  registerGuarded(defineTool({
562
748
  name: 'gotry_agent_reach',
563
749
  description:
564
- 'Agent Reach — thin wrapper over Panniantong/Agent-Reach upstream registry (zero channel knowledge here). ' +
750
+ 'Agent Reach — the PRIMARY external-data gateway (thin wrapper over Panniantong/Agent-Reach upstream registry, zero channel knowledge here). ' +
751
+ 'Prefer this for ANY external/internet fact beyond weather/flights/hotels. ' +
565
752
  'Call ANY upstream channel method by reflection: web.read(url) / v2ex.get_hot_topics() / v2ex.search(query) / ' +
566
753
  'xueqiu.get_stock_quote(symbol) / xueqiu.search_stock(query) / youtube.transcribe(url) / <channel>.check() ... ' +
567
754
  'Unknown channel or method? Just call it — the error returns the upstream inventory (channel list or method signatures) so you can self-correct. ' +
568
755
  '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). ' +
756
+ '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
757
  'Evidence chain: [agent-reach:<channel>.<method>@ts].',
571
758
  parameters: {
572
759
  query: {
@@ -580,7 +767,7 @@ export function apply(ctx: Context, config: Config): void {
580
767
  render: (_args, value) => [{ type: 'text', text: String((value as { summary?: string }).summary ?? JSON.stringify(value).slice(0, 800)) }],
581
768
  },
582
769
  async execute(args: { query: unknown }, _exec: unknown) {
583
- const q = (args.query ?? {}) as { action?: string; channel?: string; method?: string; args?: string; timeoutMs?: number }
770
+ const q = unwrapQuery<{ action?: string; channel?: string; method?: string; args?: string; timeoutMs?: number }>(args, 'channel')
584
771
  const started = Date.now()
585
772
  const dir = await ensureStateDir(config.stateRoot)
586
773
 
@@ -600,6 +787,24 @@ export function apply(ctx: Context, config: Config): void {
600
787
  return JSON.parse(JSON.stringify({ ok: false, summary: 'channel 与 method 必填(或 action=status);清单可先随便调一次,inventory 会带回上游渠道/方法表' })) as Record<string, never>
601
788
  }
602
789
  const r = await reach({ channel: q.channel, method: q.method, args: q.args, timeoutMs: q.timeoutMs })
790
+ // 长结果干净截断:数组按条目边界保留(dsh 工具上限会拦腰断 JSON,模型只能看到半条)
791
+ const dataStr = (v: unknown): unknown => {
792
+ if (Array.isArray(v)) {
793
+ const kept: unknown[] = []
794
+ let budget = 3500
795
+ for (const item of v) {
796
+ const s = JSON.stringify(item)
797
+ if (budget - s.length < 0) break
798
+ budget -= s.length + 1
799
+ kept.push(item)
800
+ }
801
+ if (kept.length < v.length) kept.push(`…(截断:保留 ${kept.length}/${v.length} 条;可用上游方法带 limit 参数取更少)`)
802
+ return kept
803
+ }
804
+ if (typeof v === 'string') return v.length > 4000 ? v.slice(0, 4000) + '…(截断)' : v
805
+ return v
806
+ }
807
+ if (r.ok) r.data = dataStr(r.data)
603
808
  await recordLatency(join(dir, 'bridge-latency.jsonl'), Date.now() - started, `agent-reach:${q.channel}.${q.method}:${r.verdict}`).catch(() => {})
604
809
  const summary = r.verdict === 'found'
605
810
  ? `${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 +820,16 @@ export function apply(ctx: Context, config: Config): void {
615
820
  latency_ms: Date.now() - started,
616
821
  })) as Record<string, never>
617
822
  },
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 }),
823
+ 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 }),
824
+ presentResult: (args, value) => {
825
+ const r = value as { verdict?: string; summary?: string }
826
+ const q = (args.query as { channel?: string; method?: string }) ?? {}
827
+ const icon = r.verdict === 'found' ? '✅' : r.verdict === 'needs-setup' ? '🔧' : r.verdict === 'not-installed' ? '📦' : '❌'
828
+ return {
829
+ card: 'generic',
830
+ title: `AgentReach ${icon} ${q.channel ?? ''}.${q.method ?? 'status'} ${r.verdict ?? ''}`,
831
+ content: [{ type: 'text', text: String(r.summary ?? '') }],
832
+ }
833
+ },
619
834
  }))
620
835
  }