@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/dist/src/index.js CHANGED
@@ -1,4 +1,6 @@
1
1
  import { join } from 'node:path';
2
+ import { readFile, writeFile } from 'node:fs/promises';
3
+ import { readFileSync } from 'node:fs';
2
4
  import z from '@deepseek-ai/schemastery';
3
5
  import { defineTool } from '@deepseek-ai/dsh-tools';
4
6
  import { ensureStateDir, readJson, recordLatency, writeJson } from './bridge.js';
@@ -7,6 +9,12 @@ import { checkConnectivity } from '../scripts/skeleton-check.js';
7
9
  import { parseCandidate, parseRequest } from './model.js';
8
10
  import { searchHotels as hbcliSearchHotels } from '../capabilities/hbcli.js';
9
11
  import { installProcessGuards, guardToolExecute } from '../capabilities/incident-log.js';
12
+ import { interpretArgs } from './tool-packet.js';
13
+ import { appendEvent, projectUtility } from './memory-utility.js';
14
+ import { pickNudgeWish } from './wish-pool.js';
15
+ import { mergeProfile } from './memory-capture.js';
16
+ import { buildTimeAnchor } from './time-anchor.js';
17
+ import { resolveSlotDate } from './slot-spec.js';
10
18
  import { geocodePlace, getForecast, getClimate, wmoLabel } from '../capabilities/weather.js';
11
19
  import { verifyFlight } from '../capabilities/opensky.js';
12
20
  import { anythingSearch } from '../capabilities/anything.js';
@@ -22,6 +30,25 @@ export const Config = z.object({
22
30
  timeoutMs: z.number().default(30_000),
23
31
  hbcliBin: z.string().default('hbcli')
24
32
  });
33
+ const unwrapQuery = interpretArgs;
34
+ function renderMotivationBrief(stateRoot) {
35
+ try {
36
+ const raw = readFileSync(join(stateRoot, 'gotry-state', 'motivation-profile.json'), 'utf-8');
37
+ const p = JSON.parse(raw);
38
+ const lines = [
39
+ '## 用户记忆(跨会话画像;与用户当轮说法冲突时以用户为准,更新经 gotry_motivation_save)'
40
+ ];
41
+ const weights = Object.entries(p.weights ?? {});
42
+ if (weights.length) lines.push(`- 动机权重: ${weights.map(([k, v])=>`${k}=${v}`).join(', ')}(证据 ${p.evidence?.length ?? 0} 条)`);
43
+ const hard = Object.entries(p.hard ?? {});
44
+ if (hard.length) lines.push(`- 硬约束: ${hard.map(([k, v])=>`${k}=${String(v)}`).join(', ')}`);
45
+ lines.push(`- 愿望池: 用 gotry_wish_pool_list 按条件召回(0..1),勿直接堆砌`);
46
+ if (p.updated_at) lines.push(`- 更新于: ${p.updated_at}`);
47
+ return weights.length || hard.length ? lines.join('\n') : '';
48
+ } catch {
49
+ return '';
50
+ }
51
+ }
25
52
  export function apply(ctx, config) {
26
53
  const sp = ctx['systemPrompt'];
27
54
  sp?.variable?.('current_date', ()=>{
@@ -38,6 +65,8 @@ export function apply(ctx, config) {
38
65
  ];
39
66
  return `${ymd} 周${weekdays[d.getDay()]}`;
40
67
  });
68
+ sp?.variable?.('time_anchor_card', ()=>buildTimeAnchor(new Date()).card);
69
+ sp?.variable?.('motivation_brief', ()=>renderMotivationBrief(config.stateRoot ?? '.'));
41
70
  installProcessGuards(config.stateRoot ?? '.', {
42
71
  uncaughtException: 'gotry-tools',
43
72
  unhandledRejection: 'gotry-tools'
@@ -53,7 +82,7 @@ export function apply(ctx, config) {
53
82
  };
54
83
  registerGuarded(defineTool({
55
84
  name: 'gotry_feasibility_check',
56
- description: 'Check travel candidates against the user\'s motivation and hard constraints using the ' + 'door-to-door true-cost engine (wake time, arrival state, energy, usable hours, money). ' + 'Input is the structured request (motivation weights + hard constraints + window + budget + home hubs) ' + 'and the candidate list (services/transfers/stay costs/min days), same shape as data/golden_erhai.json. ' + 'Returns per-candidate verdicts, unsat cores with minimal-modification suggestions, ' + 'a wish-pool entry for infeasible aspirations, and a ready-to-show markdown answer.',
85
+ description: 'Check travel candidates against the user\'s motivation and hard constraints using the ' + 'door-to-door true-cost engine (wake time, arrival state, energy, usable hours, money). ' + 'Input is the structured request (motivation weights + hard constraints + window + budget + home hubs) ' + 'and the candidate list (services/transfers/stay costs/min days): ' + 'structure { request: { motivation weights, hard constraints, window, budget, home hubs }, candidates: [ { id, label, services, transfers, stay, minDays } ] }. ' + 'Returns per-candidate verdicts, unsat cores with minimal-modification suggestions, ' + 'a wish-pool entry for infeasible aspirations, and a ready-to-show markdown answer.',
57
86
  parameters: {
58
87
  payload: {
59
88
  type: 'json',
@@ -82,6 +111,7 @@ export function apply(ctx, config) {
82
111
  const dir = await ensureStateDir(config.stateRoot);
83
112
  await recordLatency(join(dir, 'bridge-latency.jsonl'), Date.now() - started, 'feasibility_check:in-process-unified').catch(()=>{});
84
113
  return {
114
+ ok: true,
85
115
  ...result,
86
116
  latency_ms: Date.now() - started,
87
117
  via: 'in-process-unified'
@@ -90,13 +120,32 @@ export function apply(ctx, config) {
90
120
  presentCall: (args)=>({
91
121
  card: 'generic',
92
122
  title: 'GoTry 可行性检查(门到门全成本)',
93
- kind: 'other',
123
+ kind: 'execute',
94
124
  rawInput: args.payload
95
- })
125
+ }),
126
+ presentResult: (args, value)=>{
127
+ const r = value;
128
+ const budget = args.payload?.request?.budget_cny;
129
+ const lines = (r.verdicts ?? []).map((v)=>{
130
+ const tc = v.true_cost ?? {};
131
+ const money = typeof tc.money_cny === 'number' ? ` ¥${tc.money_cny}/人` + (typeof budget === 'number' ? `(预算 ¥${budget},${tc.money_cny <= budget ? '余' : '超'} ¥${Math.abs(budget - tc.money_cny)})` : '') : '';
132
+ return v.feasible ? `✅ ${String(v.name ?? v.candidate_id)}${money}${v.candidate_id === r.recommended ? ' ← 推荐' : ''}` : `❌ ${String(v.name ?? v.candidate_id)} — ${Array.isArray(v.unsat_core) ? v.unsat_core.join(',') : '不可行'}`;
133
+ });
134
+ return {
135
+ card: 'generic',
136
+ title: `可行性:${r.recommended ? `推荐 ${r.recommended}` : '全部不可行'}`,
137
+ content: [
138
+ {
139
+ type: 'text',
140
+ text: (lines.length ? lines.join('\n') + '\n\n' : '') + String(r.answer_md ?? '').slice(0, 1500)
141
+ }
142
+ ]
143
+ };
144
+ }
96
145
  }));
97
146
  registerGuarded(defineTool({
98
147
  name: 'gotry_motivation_save',
99
- description: 'Persist the traveler\'s motivation profile (the "why depart" contract object). ' + 'This is the B2B reuse seam: downstream plugins consume only MotivationProfile + constraints, ' + 'never the principal/sponsor distinction. Requires an evidence field: every weight must trace ' + 'back to the user\'s own words (P0 anti-fabrication rule).',
148
+ description: 'Persist the traveler\'s motivation profile (the "why depart" contract object). ' + 'This is the B2B reuse seam: downstream plugins consume only MotivationProfile + constraints, ' + 'never the principal/sponsor distinction. MERGE semantics (T1): call again with just the NEW ' + 'facts learned this turn (weights delta optional but MUST bring fresh evidence; evidence = user quotes); ' + 'existing history is never deleted. Requires evidence on every call (P0 anti-fabrication rule).',
100
149
  parameters: {
101
150
  profile: {
102
151
  type: 'json',
@@ -106,19 +155,7 @@ export function apply(ctx, config) {
106
155
  },
107
156
  output: {
108
157
  schema: {
109
- type: 'object',
110
- additionalProperties: false,
111
- properties: {
112
- saved: {
113
- type: 'boolean'
114
- },
115
- path: {
116
- type: 'string'
117
- },
118
- profile: {
119
- type: 'json'
120
- }
121
- }
158
+ type: 'json'
122
159
  },
123
160
  render: (_args, value)=>[
124
161
  {
@@ -128,55 +165,67 @@ export function apply(ctx, config) {
128
165
  ]
129
166
  },
130
167
  async execute (args, _exec) {
131
- const profile = args.profile ?? {};
132
- if (!profile.evidence?.length) {
168
+ const incoming = args.profile ?? {};
169
+ if (!incoming.evidence?.length) {
133
170
  throw new Error('refusing to save a motivation profile without evidence (P0 anti-fabrication rule)');
134
171
  }
135
172
  const dir = await ensureStateDir(config.stateRoot);
136
173
  const path = join(dir, 'motivation-profile.json');
174
+ let existing = null;
175
+ try {
176
+ existing = await readJson(path, null);
177
+ } catch {}
178
+ const merged = mergeProfile(existing, {
179
+ weights: incoming.weights,
180
+ evidence: incoming.evidence,
181
+ hard: incoming.hard
182
+ });
183
+ if (!merged) {
184
+ const currentJson = JSON.parse(JSON.stringify({
185
+ ...existing ?? {},
186
+ updated_at: new Date().toISOString()
187
+ }));
188
+ return {
189
+ ok: true,
190
+ saved: false,
191
+ path,
192
+ profile: currentJson,
193
+ summary: '无新内容(幂等跳过)'
194
+ };
195
+ }
137
196
  const saved = JSON.parse(JSON.stringify({
138
- ...profile,
197
+ ...merged,
139
198
  updated_at: new Date().toISOString()
140
199
  }));
141
200
  await writeJson(path, saved);
142
201
  return {
202
+ ok: true,
143
203
  saved: true,
144
204
  path,
145
- profile: saved
205
+ profile: saved,
206
+ summary: '画像已合并落盘'
146
207
  };
147
208
  },
148
209
  presentCall: (args)=>({
149
210
  card: 'generic',
150
211
  title: '保存动机画像',
151
- kind: 'other',
212
+ kind: 'edit',
152
213
  rawInput: args.profile
153
214
  })
154
215
  }));
155
216
  registerGuarded(defineTool({
156
217
  name: 'gotry_wish_pool_add',
157
- description: 'Add an aspiration to the "next departure" wish pool — the graceful home for infeasible dreams. ' + 'An entry carries its fulfilment conditions (days needed, budget, best months) so a future ' + '"next departure" nudge can fire when the window matches. 憧憬不被拒绝。',
218
+ description: 'Add an aspiration to the "next departure" wish pool — the graceful home for infeasible dreams. ' + 'An entry carries its fulfilment conditions (days needed, budget, best months) so a future ' + '"next departure" nudge can fire when the window matches. Each entry gets a stable wish_id ' + '(the memory-utility sidecar keys on it); muted:true puts a wish dormant (永不删除,只是不再召回). 憧憬不被拒绝。',
158
219
  parameters: {
159
220
  entry: {
160
221
  type: 'json',
161
222
  required: true,
162
- description: '{ name, reason, conditions: { days, budget_cny, best_months } }'
223
+ description: '{ name, reason?, conditions: { days, budget_cny, best_months }, muted?: boolean }'
163
224
  }
164
225
  },
165
226
  output: {
166
227
  schema: {
167
- type: 'object',
168
- additionalProperties: false,
169
- properties: {
170
- added: {
171
- type: 'boolean'
172
- },
173
- total: {
174
- type: 'integer'
175
- },
176
- path: {
177
- type: 'string'
178
- }
179
- }
228
+ type: 'json'
180
229
  },
181
230
  render: (_args, value)=>{
182
231
  const v = value;
@@ -198,26 +247,37 @@ export function apply(ctx, config) {
198
247
  const pool = await readJson(path, []);
199
248
  const existing = pool.findIndex((e)=>e['name'] === entry.name);
200
249
  if (existing >= 0) {
250
+ const prev = pool[existing] ?? {};
201
251
  pool[existing] = {
202
- ...pool[existing],
203
- reason: entry.reason ?? pool[existing]?.['reason'],
204
- conditions: entry.conditions
252
+ ...prev,
253
+ wish_id: prev['wish_id'] ?? `w${Date.now().toString(36)}`,
254
+ reason: entry.reason ?? prev['reason'],
255
+ conditions: entry.conditions,
256
+ ...entry.muted !== undefined ? {
257
+ muted: entry.muted
258
+ } : {}
205
259
  };
206
260
  await writeJson(path, pool);
207
261
  return {
262
+ ok: true,
208
263
  added: false,
264
+ wish_id: String(pool[existing]?.['wish_id']),
209
265
  total: pool.length,
210
266
  path
211
267
  };
212
268
  }
213
- pool.push({
269
+ const created = {
270
+ wish_id: `w${Date.now().toString(36)}`,
214
271
  reason: '',
215
272
  ...entry,
216
273
  added_at: new Date().toISOString()
217
- });
274
+ };
275
+ pool.push(created);
218
276
  await writeJson(path, pool);
219
277
  return {
278
+ ok: true,
220
279
  added: true,
280
+ wish_id: created.wish_id,
221
281
  total: pool.length,
222
282
  path
223
283
  };
@@ -225,18 +285,127 @@ export function apply(ctx, config) {
225
285
  presentCall: (args)=>({
226
286
  card: 'generic',
227
287
  title: '加入「下一次出发」清单',
228
- kind: 'other',
288
+ kind: 'edit',
229
289
  rawInput: args.entry
230
290
  })
231
291
  }));
292
+ registerGuarded(defineTool({
293
+ name: 'gotry_wish_pool_list',
294
+ description: 'Surface AT MOST ONE "next departure" wish whose fulfilment conditions match the user\'s current window ' + '(0..1 rule: never more than one nudge per turn, never push when nothing matches — 憧憬不被拒绝,也不被硬推). ' + 'Muted wishes never surface. Surfacing records a recalled event in the memory-utility sidecar. ' + 'action="confirm-outcome" records the user-confirmed real-world outcome (attribution helpful/harmful/neutral) — ' + 'ONLY pass attribution the user explicitly stated; the agent must never self-attribute usefulness.',
295
+ parameters: {
296
+ query: {
297
+ type: 'json',
298
+ required: true,
299
+ description: '{ action?: "recall"|"confirm-outcome", days?: number, budgetCny?: number, month?: number, wishId?: string, attribution?: "helpful"|"harmful"|"neutral", detail?: string }'
300
+ }
301
+ },
302
+ output: {
303
+ schema: {
304
+ type: 'json'
305
+ },
306
+ render: (_args, value)=>[
307
+ {
308
+ type: 'text',
309
+ text: String(value.summary ?? '')
310
+ }
311
+ ]
312
+ },
313
+ async execute (args, _exec) {
314
+ const q = unwrapQuery(args, 'action');
315
+ const dir = await ensureStateDir(config.stateRoot);
316
+ const pool = await readJson(join(dir, 'wish-pool.json'), []);
317
+ const sidecarPath = join(dir, 'memory-utility.jsonl');
318
+ const loadSidecar = async ()=>{
319
+ try {
320
+ const raw = await readFile(sidecarPath, 'utf-8');
321
+ return raw.split('\n').filter(Boolean).map((l)=>JSON.parse(l));
322
+ } catch {
323
+ return [];
324
+ }
325
+ };
326
+ const saveSidecar = async (events)=>{
327
+ await writeFile(sidecarPath, events.map((e)=>JSON.stringify(e)).join('\n') + '\n', 'utf-8');
328
+ };
329
+ const now = new Date().toISOString();
330
+ if (q.action === 'confirm-outcome') {
331
+ if (!q.wishId || !q.attribution) {
332
+ return {
333
+ ok: false,
334
+ summary: 'confirm-outcome 需要 wishId + attribution(helpful|harmful|neutral)'
335
+ };
336
+ }
337
+ const events = await loadSidecar();
338
+ const { events: next, appended } = appendEvent(events, {
339
+ wish_id: q.wishId,
340
+ kind: 'verified_outcome',
341
+ ts: now,
342
+ ctx: 'gotry_wish_pool_list.confirm',
343
+ detail: q.detail,
344
+ attribution: q.attribution
345
+ });
346
+ if (appended) await saveSidecar(next);
347
+ return {
348
+ ok: true,
349
+ recorded: appended,
350
+ wish_id: q.wishId,
351
+ status: q.attribution
352
+ };
353
+ }
354
+ const candidates = pool.filter((e)=>!e['muted'] && typeof e['wish_id'] === 'string');
355
+ const month = q.month ?? new Date().getMonth() + 1;
356
+ const match = pickNudgeWish(candidates, {
357
+ days: q.days,
358
+ budgetCny: q.budgetCny,
359
+ month
360
+ });
361
+ if (!match) {
362
+ return {
363
+ ok: true,
364
+ suggestion: null,
365
+ summary: `无可成行的憧憬匹配当前窗口(${candidates.length} 条在册,0..1 纪律:不硬推)`
366
+ };
367
+ }
368
+ const events = await loadSidecar();
369
+ const { events: next, appended } = appendEvent(events, {
370
+ wish_id: match.wishId,
371
+ kind: 'recalled',
372
+ ts: now,
373
+ ctx: 'gotry_wish_pool_list.recall'
374
+ });
375
+ if (appended) await saveSidecar(next);
376
+ const utility = projectUtility(next)[match.wishId];
377
+ return {
378
+ ok: true,
379
+ suggestion: {
380
+ wish_id: match.entry['wish_id'],
381
+ name: match.entry['name'],
382
+ reason: match.entry['reason'],
383
+ conditions: match.entry['conditions'],
384
+ match_score: match.score,
385
+ hits: match.hits
386
+ },
387
+ utility: {
388
+ status: utility?.status ?? 'unknown',
389
+ recalled: utility?.recalled ?? 1
390
+ },
391
+ summary: `「下一次出发」候选(0..1):${String(match.entry['name'])}——成行条件 ${JSON.stringify(match.entry['conditions'])},本次窗口命中 ${match.score}/3 项(${match.hits.join('+')});效用状态 ${utility?.status ?? 'unknown'}`
392
+ };
393
+ },
394
+ presentCall: (args)=>({
395
+ card: 'generic',
396
+ title: '「下一次出发」召回',
397
+ kind: 'search',
398
+ rawInput: args.query
399
+ })
400
+ }));
232
401
  registerGuarded(defineTool({
233
402
  name: 'gotry_hotel_search',
234
- description: 'Search hotels via hotelbyte-cli (real-time when hbcli credentials exist, falls back to the static pack with explicit evidence tagging). ' + 'Input: destination city name + optional dates/occupancy. Output: hotel list with evidence chain ([realtime-API:hbcli] + fetch timestamp, ' + 'or [static-pack:estimate]) per the L4 invariant.',
403
+ description: 'Search hotels via hotelbyte-cli (real-time when hbcli credentials exist, falls back to the static pack with explicit evidence tagging). ' + 'Input: destination city name + optional dates/occupancy. Dates accept verbatim natural expressions (下周五 / 8.20 / 下周五+3) — ' + 'the code layer resolves them against the time anchor; unresolved expressions degrade to an undated search with an explicit ' + 'date_notes entry instead of guessing. Output: hotel list with evidence chain ([realtime-API:hbcli] + fetch timestamp, ' + 'or [static-pack:estimate]) per the L4 invariant.',
235
404
  parameters: {
236
405
  query: {
237
406
  type: 'json',
238
407
  required: true,
239
- description: '{ destination: "普吉", checkIn?: "2026-07-18", checkOut?: "2026-07-23", occupancy?: { adults: 2 } }'
408
+ description: '{ destination: "<目的地城市>", checkIn?: "YYYY-MM-DD 或自然表达(下周五/8.20)", checkOut?: 同上, occupancy?: { adults: 2 } }'
240
409
  }
241
410
  },
242
411
  output: {
@@ -251,14 +420,26 @@ export function apply(ctx, config) {
251
420
  ]
252
421
  },
253
422
  async execute (args, _exec) {
254
- const q = args.query ?? {};
423
+ const q = unwrapQuery(args, 'destination');
255
424
  if (!q.destination) throw new Error('gotry_hotel_search requires destination');
256
425
  const started = Date.now();
257
426
  const fallbackPath = join(import.meta.dirname, '..', '..', 'data', 'hotels_2026.json');
427
+ const anchor = buildTimeAnchor(new Date());
428
+ const dateNotes = [];
429
+ const resolveDate = (expr)=>{
430
+ if (!expr) return undefined;
431
+ const r = resolveSlotDate(expr, anchor);
432
+ if (!r.date) {
433
+ dateNotes.push(`日期未解析:${r.raw}——请向用户确认具体日期`);
434
+ return undefined;
435
+ }
436
+ if (r.raw !== r.date) dateNotes.push(`slot-resolved: ${r.raw} → ${r.date}`);
437
+ return r.date;
438
+ };
258
439
  const resp = await hbcliSearchHotels({
259
440
  destination: q.destination,
260
- checkIn: q.checkIn,
261
- checkOut: q.checkOut,
441
+ checkIn: resolveDate(q.checkIn),
442
+ checkOut: resolveDate(q.checkOut),
262
443
  adults: q.adults
263
444
  }, {
264
445
  hbcliBin: config.hbcliBin,
@@ -270,22 +451,40 @@ export function apply(ctx, config) {
270
451
  const evidence = isLive ? resp.evidence : '[静态包:估算]';
271
452
  await recordLatency(join(dir, 'bridge-latency.jsonl'), Date.now() - started, `hotel_search:${resp.via}`).catch(()=>{});
272
453
  const payload = {
454
+ ok: true,
273
455
  hotels: resp.hotels ?? null,
274
456
  evidence,
275
457
  destination: q.destination,
276
458
  via: resp.via,
277
459
  latency_ms: Date.now() - started,
278
460
  summary: resp.summary,
279
- error: resp.error
461
+ error: resp.error,
462
+ ...dateNotes.length ? {
463
+ date_notes: dateNotes
464
+ } : {}
280
465
  };
281
466
  return JSON.parse(JSON.stringify(payload));
282
467
  },
283
468
  presentCall: (args)=>({
284
469
  card: 'generic',
285
470
  title: `酒店搜索:${String(args.query?.destination ?? '')}`,
286
- kind: 'other',
471
+ kind: 'search',
287
472
  rawInput: args.query
288
- })
473
+ }),
474
+ presentResult: (_args, value)=>{
475
+ const r = value;
476
+ const n = Array.isArray(r.hotels) ? r.hotels.length : 0;
477
+ return {
478
+ card: 'generic',
479
+ title: `酒店:${r.destination ?? ''} ${n ? `${n} 家(${r.via === 'hbcli-realtime' ? '实时' : '静态包'})` : '无结果'}`,
480
+ content: [
481
+ {
482
+ type: 'text',
483
+ text: String(r.summary ?? '')
484
+ }
485
+ ]
486
+ };
487
+ }
289
488
  }));
290
489
  registerGuarded(defineTool({
291
490
  name: 'gotry_skeleton_check',
@@ -304,38 +503,27 @@ export function apply(ctx, config) {
304
503
  },
305
504
  output: {
306
505
  schema: {
307
- type: 'object',
308
- additionalProperties: false,
309
- properties: {
310
- connected: {
311
- type: 'boolean'
312
- },
313
- airlines: {
314
- type: 'array',
315
- items: {
316
- type: 'string'
317
- }
318
- },
319
- evidence: {
320
- type: 'string'
321
- }
322
- }
506
+ type: 'json'
323
507
  },
324
508
  render: (_args, value)=>[
325
509
  {
326
510
  type: 'text',
327
- text: String(value.evidence ?? '')
511
+ text: String(value.evidence ?? JSON.stringify(value))
328
512
  }
329
513
  ]
330
514
  },
331
515
  async execute (args, _exec) {
332
- const verdict = await checkConnectivity(args.from, args.to);
333
- return JSON.parse(JSON.stringify(verdict));
516
+ const q = unwrapQuery(args);
517
+ const verdict = await checkConnectivity(String(q.from ?? ''), String(q.to ?? ''));
518
+ return JSON.parse(JSON.stringify({
519
+ ok: true,
520
+ ...verdict
521
+ }));
334
522
  },
335
523
  presentCall: (args)=>({
336
524
  card: 'generic',
337
525
  title: `骨架校验:${args.from}-${args.to}`,
338
- kind: 'other',
526
+ kind: 'execute',
339
527
  rawInput: args
340
528
  })
341
529
  }));
@@ -346,7 +534,7 @@ export function apply(ctx, config) {
346
534
  query: {
347
535
  type: 'json',
348
536
  required: true,
349
- description: '{ place: "大理市", month?: 8, mode?: "forecast"|"climate", days?: 7 }'
537
+ description: '{ place: "<城市名>", month?: 8, mode?: "forecast"|"climate", days?: 7 }'
350
538
  }
351
539
  },
352
540
  output: {
@@ -361,7 +549,7 @@ export function apply(ctx, config) {
361
549
  ]
362
550
  },
363
551
  async execute (args, _exec) {
364
- const q = args.query ?? {};
552
+ const q = unwrapQuery(args, 'place');
365
553
  const started = Date.now();
366
554
  let lat = q.lat, lng = q.lng;
367
555
  let placeLabel = q.place ?? `${q.lat},${q.lng}`;
@@ -407,9 +595,24 @@ export function apply(ctx, config) {
407
595
  presentCall: (args)=>({
408
596
  card: 'generic',
409
597
  title: `天气:${String(args.query?.place ?? '')}`,
410
- kind: 'other',
598
+ kind: 'fetch',
411
599
  rawInput: args.query
412
- })
600
+ }),
601
+ presentResult: (args, value)=>{
602
+ const r = value;
603
+ const place = String(args.query?.place ?? '');
604
+ const failed = String(r.summary ?? '').includes('降级') || String(r.summary ?? '').includes('unavailable');
605
+ return {
606
+ card: 'generic',
607
+ title: `天气:${place} ${failed ? '降级' : 'ok'}`,
608
+ content: [
609
+ {
610
+ type: 'text',
611
+ text: String(r.summary ?? '')
612
+ }
613
+ ]
614
+ };
615
+ }
413
616
  }));
414
617
  registerGuarded(defineTool({
415
618
  name: 'gotry_flight_verify',
@@ -433,7 +636,7 @@ export function apply(ctx, config) {
433
636
  ]
434
637
  },
435
638
  async execute (args, _exec) {
436
- const q = args.query ?? {};
639
+ const q = unwrapQuery(args, 'callsign');
437
640
  const started = Date.now();
438
641
  if (!q.callsign) {
439
642
  return JSON.parse(JSON.stringify({
@@ -451,6 +654,7 @@ export function apply(ctx, config) {
451
654
  await recordLatency(join(dir, 'bridge-latency.jsonl'), Date.now() - started, `flight_verify:${r.via}`).catch(()=>{});
452
655
  const summary = r.verdict === 'observed' ? `${r.callsign} 当前 ADS-B 观测命中 (${r.hits?.length ?? 0} 架)${r.airport ? ` 在 ${r.airport}` : ''}\n${r.evidence}` : r.verdict === 'not_observed' ? `${r.callsign} 当前观测列表未见(ADS-B 覆盖有限,不否定该航班存在)\n${r.evidence}` : `${r.callsign} OpenSky 不可用:${r.error}\n${r.evidence}`;
453
656
  return JSON.parse(JSON.stringify({
657
+ ok: true,
454
658
  verdict: r.verdict,
455
659
  callsign: r.callsign,
456
660
  airport: r.airport,
@@ -464,18 +668,18 @@ export function apply(ctx, config) {
464
668
  presentCall: (args)=>({
465
669
  card: 'generic',
466
670
  title: `飞行校验:${String(args.query?.callsign ?? '')}`,
467
- kind: 'other',
671
+ kind: 'fetch',
468
672
  rawInput: args.query
469
673
  })
470
674
  }));
471
675
  registerGuarded(defineTool({
472
676
  name: 'gotry_anything_search',
473
- description: 'Universal Anything search via hotel-byte CLI → hotel-be Anything endpoint. ' + 'Mixed destinations (cities / metropolitan areas / high-level regions) + hotels in one call. ' + 'Returns candidates with type, name, optional coordinates and hotel-id. ' + 'Three-valued semantics: hit = ≥1 candidate; miss = 0 candidates (try synonyms or contentType=city/hotel); ' + 'unavailable = hbcli failed (degraded, never blocks). ' + 'Use as the first stop when the user mentions a place/city/hotel name and you need to ground it in real catalog data ' + '(OpenFlights skeleton tells you connectivity; Anything tells you what EXISTS at a city/region).',
677
+ description: '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. ' + 'Mixed destinations (cities / metropolitan areas / high-level regions) + hotels in one call. ' + 'Returns candidates with type, name, optional coordinates and hotel-id. ' + 'Three-valued semantics: hit = ≥1 candidate; miss = 0 candidates (try synonyms or contentType=city/hotel); ' + 'unavailable = hbcli failed (degraded, never blocks). ' + 'Use as the first stop when the user mentions a place/city/hotel name and you need to ground it in real catalog data ' + '(OpenFlights skeleton tells you connectivity; Anything tells you what EXISTS at a city/region).',
474
678
  parameters: {
475
679
  query: {
476
680
  type: 'json',
477
681
  required: true,
478
- description: '{ keyword: "大理", contentType?: "city"|"hotel", parentDestinationId?: "?", timeoutMs?: 12000 }'
682
+ description: '{ keyword: "<搜索关键词>", contentType?: "city"|"hotel", parentDestinationId?: "?", timeoutMs?: 12000 }'
479
683
  }
480
684
  },
481
685
  output: {
@@ -490,7 +694,7 @@ export function apply(ctx, config) {
490
694
  ]
491
695
  },
492
696
  async execute (args, _exec) {
493
- const q = args.query ?? {};
697
+ const q = unwrapQuery(args, 'keyword');
494
698
  const started = Date.now();
495
699
  if (!q.keyword) {
496
700
  return JSON.parse(JSON.stringify({
@@ -520,9 +724,23 @@ export function apply(ctx, config) {
520
724
  presentCall: (args)=>({
521
725
  card: 'generic',
522
726
  title: `Anything search:${String(args.query?.keyword ?? '')}`,
523
- kind: 'other',
727
+ kind: 'search',
524
728
  rawInput: args.query
525
- })
729
+ }),
730
+ presentResult: (_args, value)=>{
731
+ const r = value;
732
+ const n = Array.isArray(r.hits) ? r.hits.length : r.total_candidates ?? 0;
733
+ return {
734
+ card: 'generic',
735
+ title: `Anything:${r.keyword ?? ''} ${r.verdict === 'hit' ? `${n} hits` : r.verdict ?? 'no-result'}`,
736
+ content: [
737
+ {
738
+ type: 'text',
739
+ text: String(r.summary ?? '')
740
+ }
741
+ ]
742
+ };
743
+ }
526
744
  }));
527
745
  registerGuarded(defineTool({
528
746
  name: 'gotry_web_search',
@@ -546,7 +764,7 @@ export function apply(ctx, config) {
546
764
  ]
547
765
  },
548
766
  async execute (args, _exec) {
549
- const q = args.query ?? {};
767
+ const q = unwrapQuery(args, 'url');
550
768
  const started = Date.now();
551
769
  if (!q.url) {
552
770
  return JSON.parse(JSON.stringify({
@@ -576,7 +794,7 @@ export function apply(ctx, config) {
576
794
  presentCall: (args)=>({
577
795
  card: 'generic',
578
796
  title: `读网页:${String(args.query?.url ?? '')}`,
579
- kind: 'other',
797
+ kind: 'fetch',
580
798
  rawInput: args.query
581
799
  })
582
800
  }));
@@ -602,7 +820,7 @@ export function apply(ctx, config) {
602
820
  ]
603
821
  },
604
822
  async execute (args, _exec) {
605
- const q = args.query ?? {};
823
+ const q = unwrapQuery(args, 'url');
606
824
  if (!q.url) {
607
825
  return JSON.parse(JSON.stringify({
608
826
  ok: false,
@@ -627,7 +845,7 @@ export function apply(ctx, config) {
627
845
  presentCall: (args)=>({
628
846
  card: 'generic',
629
847
  title: `视频字幕:${String(args.query?.url ?? '')}`,
630
- kind: 'other',
848
+ kind: 'fetch',
631
849
  rawInput: args.query
632
850
  })
633
851
  }));
@@ -653,7 +871,7 @@ export function apply(ctx, config) {
653
871
  ]
654
872
  },
655
873
  async execute (args, _exec) {
656
- const q = args.query ?? {};
874
+ const q = unwrapQuery(args, 'query');
657
875
  if (!q.query) {
658
876
  return JSON.parse(JSON.stringify({
659
877
  ok: false,
@@ -678,13 +896,13 @@ export function apply(ctx, config) {
678
896
  presentCall: (args)=>({
679
897
  card: 'generic',
680
898
  title: `GitHub 搜索:${String(args.query?.query ?? '')}`,
681
- kind: 'other',
899
+ kind: 'search',
682
900
  rawInput: args.query
683
901
  })
684
902
  }));
685
903
  registerGuarded(defineTool({
686
904
  name: 'gotry_agent_reach',
687
- description: 'Agent Reach — thin wrapper over Panniantong/Agent-Reach upstream registry (zero channel knowledge here). ' + 'Call ANY upstream channel method by reflection: web.read(url) / v2ex.get_hot_topics() / v2ex.search(query) / ' + 'xueqiu.get_stock_quote(symbol) / xueqiu.search_stock(query) / youtube.transcribe(url) / <channel>.check() ... ' + 'Unknown channel or method? Just call it — the error returns the upstream inventory (channel list or method signatures) so you can self-correct. ' + 'Action "status" runs the real `agent-reach doctor` (.venv/bin/agent-reach). ' + 'Channels needing cookies/setup return the upstream check() guidance verbatim (never blocks). ' + 'Evidence chain: [agent-reach:<channel>.<method>@ts].',
905
+ description: 'Agent Reach — the PRIMARY external-data gateway (thin wrapper over Panniantong/Agent-Reach upstream registry, zero channel knowledge here). ' + 'Prefer this for ANY external/internet fact beyond weather/flights/hotels. ' + 'Call ANY upstream channel method by reflection: web.read(url) / v2ex.get_hot_topics() / v2ex.search(query) / ' + 'xueqiu.get_stock_quote(symbol) / xueqiu.search_stock(query) / youtube.transcribe(url) / <channel>.check() ... ' + 'Unknown channel or method? Just call it — the error returns the upstream inventory (channel list or method signatures) so you can self-correct. ' + 'Action "status" runs the real `agent-reach doctor` (.venv/bin/agent-reach). ' + '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. ' + 'Evidence chain: [agent-reach:<channel>.<method>@ts].',
688
906
  parameters: {
689
907
  query: {
690
908
  type: 'json',
@@ -704,7 +922,7 @@ export function apply(ctx, config) {
704
922
  ]
705
923
  },
706
924
  async execute (args, _exec) {
707
- const q = args.query ?? {};
925
+ const q = unwrapQuery(args, 'channel');
708
926
  const started = Date.now();
709
927
  const dir = await ensureStateDir(config.stateRoot);
710
928
  if (q.action === 'status' || !q.action && !q.channel) {
@@ -732,6 +950,23 @@ export function apply(ctx, config) {
732
950
  args: q.args,
733
951
  timeoutMs: q.timeoutMs
734
952
  });
953
+ const dataStr = (v)=>{
954
+ if (Array.isArray(v)) {
955
+ const kept = [];
956
+ let budget = 3500;
957
+ for (const item of v){
958
+ const s = JSON.stringify(item);
959
+ if (budget - s.length < 0) break;
960
+ budget -= s.length + 1;
961
+ kept.push(item);
962
+ }
963
+ if (kept.length < v.length) kept.push(`…(截断:保留 ${kept.length}/${v.length} 条;可用上游方法带 limit 参数取更少)`);
964
+ return kept;
965
+ }
966
+ if (typeof v === 'string') return v.length > 4000 ? v.slice(0, 4000) + '…(截断)' : v;
967
+ return v;
968
+ };
969
+ if (r.ok) r.data = dataStr(r.data);
735
970
  await recordLatency(join(dir, 'bridge-latency.jsonl'), Date.now() - started, `agent-reach:${q.channel}.${q.method}:${r.verdict}`).catch(()=>{});
736
971
  const summary = r.verdict === 'found' ? `${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)}` : r.verdict === 'needs-setup' ? `${q.channel}.${q.method} → 需配置(上游 check() 原话): ${r.setup ?? ''}\n${r.evidence}` : r.verdict === 'not-installed' ? `${q.channel}.${q.method} → 上游未装: ${r.setup ?? ''}\n${r.evidence}` : `${q.channel}.${q.method} → ${r.error ?? 'error'}${r.inventory ? `\n上游清单: ${JSON.stringify(r.inventory).slice(0, 1200)}` : ''}\n${r.evidence}`;
737
972
  return JSON.parse(JSON.stringify({
@@ -750,9 +985,24 @@ export function apply(ctx, config) {
750
985
  presentCall: (args)=>({
751
986
  card: 'generic',
752
987
  title: `Agent Reach:${String(args.query?.channel ?? '')}.${String(args.query?.method ?? 'status')}`,
753
- kind: 'other',
988
+ kind: 'fetch',
754
989
  rawInput: args.query
755
- })
990
+ }),
991
+ presentResult: (args, value)=>{
992
+ const r = value;
993
+ const q = args.query ?? {};
994
+ const icon = r.verdict === 'found' ? '✅' : r.verdict === 'needs-setup' ? '🔧' : r.verdict === 'not-installed' ? '📦' : '❌';
995
+ return {
996
+ card: 'generic',
997
+ title: `AgentReach ${icon} ${q.channel ?? ''}.${q.method ?? 'status'} ${r.verdict ?? ''}`,
998
+ content: [
999
+ {
1000
+ type: 'text',
1001
+ text: String(r.summary ?? '')
1002
+ }
1003
+ ]
1004
+ };
1005
+ }
756
1006
  }));
757
1007
  }
758
1008