@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/dist/src/index.js CHANGED
@@ -1,17 +1,26 @@
1
1
  import { join } from 'node:path';
2
2
  import z from '@deepseek-ai/schemastery';
3
3
  import { defineTool } from '@deepseek-ai/dsh-tools';
4
- import { ensureStateDir, readJson, recordLatency, writeJson } from './bridge.js';
4
+ import { ensureStateDir, recordLatency } from './bridge.js';
5
5
  import { segmentsFromCandidate, solveChoiceSegment } from './unified.js';
6
6
  import { checkConnectivity } from '../scripts/skeleton-check.js';
7
7
  import { parseCandidate, parseRequest } from './model.js';
8
8
  import { searchHotels as hbcliSearchHotels } from '../capabilities/hbcli.js';
9
9
  import { installProcessGuards, guardToolExecute } from '../capabilities/incident-log.js';
10
+ import { interpretArgs } from './tool-packet.js';
11
+ import { projectUtility } from './memory-utility.js';
12
+ import { pickNudgeWish } from './wish-pool.js';
13
+ import { resolveTimelineDate } from './travel-timeline.js';
14
+ import { ensureLedger, readCompanionsWithFallback, readMotivationWithFallback, readTripsWithFallback, readWishPoolWithFallback } from './state-ledger.js';
15
+ import { buildTimeAnchor } from './time-anchor.js';
16
+ import { resolveSlotDate } from './slot-spec.js';
10
17
  import { geocodePlace, getForecast, getClimate, wmoLabel } from '../capabilities/weather.js';
11
18
  import { verifyFlight } from '../capabilities/opensky.js';
12
19
  import { anythingSearch } from '../capabilities/anything.js';
13
20
  import { readUrl, reach, reachStatus } from '../capabilities/agent-reach.js';
14
21
  import { videoSubtitle, githubSearch } from '../capabilities/agent-reach-deep.js';
22
+ import { flyaiSearch } from '../capabilities/flyai.js';
23
+ import { sessionFlightSearch } from '../capabilities/session-search.js';
15
24
  export const name = 'gotry-tools';
16
25
  export const inject = [
17
26
  'tools',
@@ -22,6 +31,48 @@ export const Config = z.object({
22
31
  timeoutMs: z.number().default(30_000),
23
32
  hbcliBin: z.string().default('hbcli')
24
33
  });
34
+ const unwrapQuery = interpretArgs;
35
+ function renderMotivationBrief(stateRoot) {
36
+ const p = readMotivationWithFallback(stateRoot);
37
+ if (!p) return '';
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
+ const trips = readTimelineTrips(stateRoot);
46
+ if (trips.length) {
47
+ lines.push(`- 去过: ${trips.map((t)=>`${t.destination}(${t.start.slice(0, 7)})`).join('、')}——去过的地方不再主动推荐,除非用户点名`);
48
+ }
49
+ const companions = readCompanions(stateRoot);
50
+ if (companions.length) {
51
+ lines.push(`- 同行人: ${companions.map((c)=>`${c.label}(${c.brief})`).join('、')}——约束进结构与排序建议,不硬过滤;引用时带当初的原话依据`);
52
+ }
53
+ lines.push(`- 愿望池: 用 gotry_wish_pool_list 按条件召回(0..1),勿直接堆砌`);
54
+ if (p.updated_at) lines.push(`- 更新于: ${p.updated_at}`);
55
+ return weights.length || hard.length ? lines.join('\n') : '';
56
+ }
57
+ function readCompanions(stateRoot) {
58
+ return readCompanionsWithFallback(stateRoot).map((c)=>{
59
+ const bits = [
60
+ c.constraints.mobility,
61
+ ...c.constraints.health ?? [],
62
+ ...c.constraints.prefs ?? []
63
+ ].filter(Boolean);
64
+ return {
65
+ label: c.label,
66
+ brief: bits.join('/')
67
+ };
68
+ });
69
+ }
70
+ function readTimelineTrips(stateRoot) {
71
+ return readTripsWithFallback(stateRoot).map((t)=>({
72
+ destination: t.destination,
73
+ start: t.start
74
+ })).sort((a, b)=>a.start < b.start ? 1 : -1).slice(0, 3);
75
+ }
25
76
  export function apply(ctx, config) {
26
77
  const sp = ctx['systemPrompt'];
27
78
  sp?.variable?.('current_date', ()=>{
@@ -38,6 +89,8 @@ export function apply(ctx, config) {
38
89
  ];
39
90
  return `${ymd} 周${weekdays[d.getDay()]}`;
40
91
  });
92
+ sp?.variable?.('time_anchor_card', ()=>buildTimeAnchor(new Date()).card);
93
+ sp?.variable?.('motivation_brief', ()=>renderMotivationBrief(config.stateRoot ?? '.'));
41
94
  installProcessGuards(config.stateRoot ?? '.', {
42
95
  uncaughtException: 'gotry-tools',
43
96
  unhandledRejection: 'gotry-tools'
@@ -53,7 +106,7 @@ export function apply(ctx, config) {
53
106
  };
54
107
  registerGuarded(defineTool({
55
108
  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.',
109
+ 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
110
  parameters: {
58
111
  payload: {
59
112
  type: 'json',
@@ -82,6 +135,7 @@ export function apply(ctx, config) {
82
135
  const dir = await ensureStateDir(config.stateRoot);
83
136
  await recordLatency(join(dir, 'bridge-latency.jsonl'), Date.now() - started, 'feasibility_check:in-process-unified').catch(()=>{});
84
137
  return {
138
+ ok: true,
85
139
  ...result,
86
140
  latency_ms: Date.now() - started,
87
141
  via: 'in-process-unified'
@@ -90,13 +144,32 @@ export function apply(ctx, config) {
90
144
  presentCall: (args)=>({
91
145
  card: 'generic',
92
146
  title: 'GoTry 可行性检查(门到门全成本)',
93
- kind: 'other',
147
+ kind: 'execute',
94
148
  rawInput: args.payload
95
- })
149
+ }),
150
+ presentResult: (args, value)=>{
151
+ const r = value;
152
+ const budget = args.payload?.request?.budget_cny;
153
+ const lines = (r.verdicts ?? []).map((v)=>{
154
+ const tc = v.true_cost ?? {};
155
+ const money = typeof tc.money_cny === 'number' ? ` ¥${tc.money_cny}/人` + (typeof budget === 'number' ? `(预算 ¥${budget},${tc.money_cny <= budget ? '余' : '超'} ¥${Math.abs(budget - tc.money_cny)})` : '') : '';
156
+ 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(',') : '不可行'}`;
157
+ });
158
+ return {
159
+ card: 'generic',
160
+ title: `可行性:${r.recommended ? `推荐 ${r.recommended}` : '全部不可行'}`,
161
+ content: [
162
+ {
163
+ type: 'text',
164
+ text: (lines.length ? lines.join('\n') + '\n\n' : '') + String(r.answer_md ?? '').slice(0, 1500)
165
+ }
166
+ ]
167
+ };
168
+ }
96
169
  }));
97
170
  registerGuarded(defineTool({
98
171
  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).',
172
+ 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
173
  parameters: {
101
174
  profile: {
102
175
  type: 'json',
@@ -106,19 +179,7 @@ export function apply(ctx, config) {
106
179
  },
107
180
  output: {
108
181
  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
- }
182
+ type: 'json'
122
183
  },
123
184
  render: (_args, value)=>[
124
185
  {
@@ -128,55 +189,51 @@ export function apply(ctx, config) {
128
189
  ]
129
190
  },
130
191
  async execute (args, _exec) {
131
- const profile = args.profile ?? {};
132
- if (!profile.evidence?.length) {
192
+ const incoming = args.profile ?? {};
193
+ if (!incoming.evidence?.length) {
133
194
  throw new Error('refusing to save a motivation profile without evidence (P0 anti-fabrication rule)');
134
195
  }
135
- const dir = await ensureStateDir(config.stateRoot);
136
- const path = join(dir, 'motivation-profile.json');
137
- const saved = JSON.parse(JSON.stringify({
138
- ...profile,
139
- updated_at: new Date().toISOString()
140
- }));
141
- await writeJson(path, saved);
142
- return {
196
+ const ledger = ensureLedger(config.stateRoot);
197
+ const res = ledger.appendMotivationPatch({
198
+ weights: incoming.weights,
199
+ evidence: incoming.evidence,
200
+ hard: incoming.hard
201
+ });
202
+ const profileJson = JSON.parse(JSON.stringify(res.profile));
203
+ return res.saved ? {
204
+ ok: true,
143
205
  saved: true,
144
- path,
145
- profile: saved
206
+ path: ledger.dbPath,
207
+ profile: profileJson,
208
+ summary: '画像已合并入账本(单事务)'
209
+ } : {
210
+ ok: true,
211
+ saved: false,
212
+ path: ledger.dbPath,
213
+ profile: profileJson,
214
+ summary: '无新内容(幂等跳过)'
146
215
  };
147
216
  },
148
217
  presentCall: (args)=>({
149
218
  card: 'generic',
150
219
  title: '保存动机画像',
151
- kind: 'other',
220
+ kind: 'edit',
152
221
  rawInput: args.profile
153
222
  })
154
223
  }));
155
224
  registerGuarded(defineTool({
156
225
  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. 憧憬不被拒绝。',
226
+ 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
227
  parameters: {
159
228
  entry: {
160
229
  type: 'json',
161
230
  required: true,
162
- description: '{ name, reason, conditions: { days, budget_cny, best_months } }'
231
+ description: '{ name, reason?, conditions: { days, budget_cny, best_months }, muted?: boolean }'
163
232
  }
164
233
  },
165
234
  output: {
166
235
  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
- }
236
+ type: 'json'
180
237
  },
181
238
  render: (_args, value)=>{
182
239
  const v = value;
@@ -190,53 +247,267 @@ export function apply(ctx, config) {
190
247
  },
191
248
  async execute (args, _exec) {
192
249
  const entry = args.entry ?? {};
193
- if (!entry.name || !entry.conditions) {
194
- throw new Error('wish pool entry requires name and conditions (fulfilment conditions are the whole point)');
250
+ const ledger = ensureLedger(config.stateRoot);
251
+ const r = ledger.appendWish({
252
+ name: entry.name,
253
+ reason: entry.reason,
254
+ conditions: entry.conditions,
255
+ muted: entry.muted
256
+ });
257
+ return {
258
+ ok: true,
259
+ added: r.added,
260
+ wish_id: r.wish_id,
261
+ total: r.total,
262
+ path: ledger.dbPath
263
+ };
264
+ },
265
+ presentCall: (args)=>({
266
+ card: 'generic',
267
+ title: '加入「下一次出发」清单',
268
+ kind: 'edit',
269
+ rawInput: args.entry
270
+ })
271
+ }));
272
+ registerGuarded(defineTool({
273
+ name: 'gotry_wish_pool_list',
274
+ 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.',
275
+ parameters: {
276
+ query: {
277
+ type: 'json',
278
+ required: true,
279
+ description: '{ action?: "recall"|"confirm-outcome", days?, budgetCny?, month?, wishId?, attribution?: "helpful"|"harmful"|"neutral", detail?, tripStart?: "行程起始(确认成行时挂时间线)" }'
195
280
  }
196
- const dir = await ensureStateDir(config.stateRoot);
197
- const path = join(dir, 'wish-pool.json');
198
- const pool = await readJson(path, []);
199
- const existing = pool.findIndex((e)=>e['name'] === entry.name);
200
- if (existing >= 0) {
201
- pool[existing] = {
202
- ...pool[existing],
203
- reason: entry.reason ?? pool[existing]?.['reason'],
204
- conditions: entry.conditions
281
+ },
282
+ output: {
283
+ schema: {
284
+ type: 'json'
285
+ },
286
+ render: (_args, value)=>[
287
+ {
288
+ type: 'text',
289
+ text: String(value.summary ?? '')
290
+ }
291
+ ]
292
+ },
293
+ async execute (args, _exec) {
294
+ const q = unwrapQuery(args, 'action');
295
+ const ledger = ensureLedger(config.stateRoot);
296
+ const now = new Date().toISOString();
297
+ if (q.action === 'confirm-outcome') {
298
+ if (!q.wishId || !q.attribution) {
299
+ return {
300
+ ok: false,
301
+ summary: 'confirm-outcome 需要 wishId + attribution(helpful|harmful|neutral)'
302
+ };
303
+ }
304
+ let tripInput;
305
+ const wish = ledger.readWishPool().find((e)=>String(e.wish_id) === q.wishId);
306
+ if (q.tripStart && wish?.name) {
307
+ const anchor = buildTimeAnchor(new Date());
308
+ const start = resolveTimelineDate(q.tripStart, anchor) ?? (/^\d{4}-\d{2}-\d{2}$/.test(q.tripStart) ? q.tripStart : undefined);
309
+ if (start) {
310
+ tripInput = {
311
+ destination: String(wish.name),
312
+ start,
313
+ companions: undefined,
314
+ source: 'wish-confirmed',
315
+ evidence: `wish ${q.wishId} confirm-outcome(${q.attribution})`
316
+ };
317
+ }
318
+ }
319
+ const r = ledger.confirmOutcome({
320
+ wishId: q.wishId,
321
+ attribution: q.attribution,
322
+ detail: q.detail,
323
+ trip: tripInput
324
+ });
325
+ return {
326
+ ok: true,
327
+ recorded: r.recorded,
328
+ wish_id: q.wishId,
329
+ status: q.attribution,
330
+ ...r.trip ? {
331
+ trip: r.trip
332
+ } : {}
205
333
  };
206
- await writeJson(path, pool);
334
+ }
335
+ const pool = ledger.readWishPool();
336
+ const candidates = pool.filter((e)=>!e.muted && typeof e.wish_id === 'string');
337
+ const month = q.month ?? new Date().getMonth() + 1;
338
+ const match = pickNudgeWish(candidates, {
339
+ days: q.days,
340
+ budgetCny: q.budgetCny,
341
+ month
342
+ });
343
+ if (!match) {
207
344
  return {
208
- added: false,
209
- total: pool.length,
210
- path
345
+ ok: true,
346
+ suggestion: null,
347
+ summary: `无可成行的憧憬匹配当前窗口(${candidates.length} 条在册,0..1 纪律:不硬推)`
211
348
  };
212
349
  }
213
- pool.push({
214
- reason: '',
215
- ...entry,
216
- added_at: new Date().toISOString()
350
+ const { events: next } = ledger.appendUtilityEvent({
351
+ wish_id: match.wishId,
352
+ kind: 'recalled',
353
+ ts: now,
354
+ ctx: 'gotry_wish_pool_list.recall'
217
355
  });
218
- await writeJson(path, pool);
356
+ const utility = projectUtility(next)[match.wishId];
219
357
  return {
220
- added: true,
221
- total: pool.length,
222
- path
358
+ ok: true,
359
+ suggestion: {
360
+ wish_id: match.entry['wish_id'],
361
+ name: match.entry['name'],
362
+ reason: match.entry['reason'],
363
+ conditions: match.entry['conditions'],
364
+ match_score: match.score,
365
+ hits: match.hits
366
+ },
367
+ utility: {
368
+ status: utility?.status ?? 'unknown',
369
+ recalled: utility?.recalled ?? 1
370
+ },
371
+ summary: `「下一次出发」候选(0..1):${String(match.entry['name'])}——成行条件 ${JSON.stringify(match.entry['conditions'])},本次窗口命中 ${match.score}/3 项(${match.hits.join('+')});效用状态 ${utility?.status ?? 'unknown'}`
223
372
  };
224
373
  },
225
374
  presentCall: (args)=>({
226
375
  card: 'generic',
227
- title: '加入「下一次出发」清单',
228
- kind: 'other',
229
- rawInput: args.entry
376
+ title: '「下一次出发」召回',
377
+ kind: 'search',
378
+ rawInput: args.query
379
+ })
380
+ }));
381
+ registerGuarded(defineTool({
382
+ name: 'gotry_companion_save',
383
+ description: 'Save/update a travel companion profile (memory-design M5 layer): constraints that shape itinerary STRUCTURE and ranking — ' + 'never a hard filter (「爸爸65轻度高血压」→ 不排高海拔/控制步行量;「晕车」→ 优先火车/备提示). ' + 'evidence MUST be the user\'s verbatim words (append-only, traceable). ' + 'NEGATIVE LIST: passport/ID/phone numbers are rejected on sight — such fields never enter storage.',
384
+ parameters: {
385
+ companion: {
386
+ type: 'json',
387
+ required: true,
388
+ description: '{ label: "爸爸", constraints: { mobility?: "步行≤4h", health?: ["轻度高血压"], prefs?: ["怕吵"] }, evidence: "<用户原话>" }'
389
+ }
390
+ },
391
+ output: {
392
+ schema: {
393
+ type: 'json'
394
+ },
395
+ render: (_args, value)=>[
396
+ {
397
+ type: 'text',
398
+ text: String(value.summary ?? '')
399
+ }
400
+ ]
401
+ },
402
+ async execute (args, _exec) {
403
+ const c = args.companion ?? {};
404
+ if (!c.evidence) return {
405
+ ok: false,
406
+ summary: 'evidence 必填(用户原话,溯源 P0)'
407
+ };
408
+ const ledger = ensureLedger(config.stateRoot);
409
+ const res = ledger.appendCompanion({
410
+ label: String(c.label ?? ''),
411
+ constraints: {
412
+ mobility: c.constraints?.mobility,
413
+ health: c.constraints?.health,
414
+ prefs: c.constraints?.prefs
415
+ },
416
+ evidence: c.evidence
417
+ });
418
+ if (!res.appended && res.reason) return {
419
+ ok: false,
420
+ summary: res.reason
421
+ };
422
+ return {
423
+ ok: true,
424
+ appended: res.appended,
425
+ companion_id: res.companionId,
426
+ total: res.total,
427
+ summary: res.appended ? `同行人已保存:${res.companionId}` : `无新内容(幂等跳过):${res.companionId}`
428
+ };
429
+ },
430
+ presentCall: (args)=>({
431
+ card: 'generic',
432
+ title: '保存同行人',
433
+ kind: 'edit',
434
+ rawInput: args.companion
435
+ })
436
+ }));
437
+ registerGuarded(defineTool({
438
+ name: 'gotry_trip_log',
439
+ description: 'Record a PAST trip into the travel timeline (memory-design M4 layer). Use when the user mentions a trip they already took ' + '(「去年国庆去了大理」) — dates resolve via the time anchor (词表外日期会拒绝,请用户给绝对日期), evidence MUST be the user\'s verbatim words. ' + 'Append-only and idempotent (same destination+start+source = same trip); overlapping same-destination trips are rejected for human adjudication. ' + 'Past trips power origin resolution and 「去过不再推」 ranking — never a hard filter.',
440
+ parameters: {
441
+ query: {
442
+ type: 'json',
443
+ required: true,
444
+ description: '{ destination: "大理", start: "2025-10-01 或绝对日期表达", end?: 同上, companions?: ["爸爸"], evidence: "<用户原话>" }'
445
+ }
446
+ },
447
+ output: {
448
+ schema: {
449
+ type: 'json'
450
+ },
451
+ render: (_args, value)=>[
452
+ {
453
+ type: 'text',
454
+ text: String(value.summary ?? '')
455
+ }
456
+ ]
457
+ },
458
+ async execute (args, _exec) {
459
+ const q = unwrapQuery(args, 'destination');
460
+ if (!q.destination) return {
461
+ ok: false,
462
+ summary: 'destination 必填'
463
+ };
464
+ if (!q.evidence) return {
465
+ ok: false,
466
+ summary: 'evidence 必填(用户原话,溯源 P0)'
467
+ };
468
+ const anchor = buildTimeAnchor(new Date());
469
+ const start = resolveTimelineDate(q.start ?? '', anchor) ?? (/^\d{4}-\d{2}-\d{2}$/.test(q.start ?? '') ? q.start : undefined);
470
+ if (!start) return {
471
+ ok: false,
472
+ summary: `start 无法解析为绝对日期(${q.start ?? '缺'})——请向用户要具体日期,不猜`
473
+ };
474
+ const end = q.end ? resolveTimelineDate(q.end, anchor) ?? (/^\d{4}-\d{2}-\d{2}$/.test(q.end) ? q.end : undefined) : undefined;
475
+ const ledger = ensureLedger(config.stateRoot);
476
+ const res = ledger.appendTripEvent({
477
+ destination: q.destination.trim(),
478
+ start,
479
+ end,
480
+ companions: q.companions,
481
+ source: 'user-verbatim',
482
+ evidence: q.evidence
483
+ });
484
+ if (!res.appended && res.reason) return {
485
+ ok: false,
486
+ summary: res.reason
487
+ };
488
+ return {
489
+ ok: true,
490
+ appended: res.appended,
491
+ trip_id: res.tripId,
492
+ total: res.total,
493
+ summary: res.appended ? `已入时间线:${q.destination} @ ${start}` : `已存在(幂等跳过):${res.tripId}`
494
+ };
495
+ },
496
+ presentCall: (args)=>({
497
+ card: 'generic',
498
+ title: '记录旅行',
499
+ kind: 'edit',
500
+ rawInput: args.query
230
501
  })
231
502
  }));
232
503
  registerGuarded(defineTool({
233
504
  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.',
505
+ 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
506
  parameters: {
236
507
  query: {
237
508
  type: 'json',
238
509
  required: true,
239
- description: '{ destination: "普吉", checkIn?: "2026-07-18", checkOut?: "2026-07-23", occupancy?: { adults: 2 } }'
510
+ description: '{ destination: "<目的地城市>", checkIn?: "YYYY-MM-DD 或自然表达(下周五/8.20)", checkOut?: 同上, occupancy?: { adults: 2 } }'
240
511
  }
241
512
  },
242
513
  output: {
@@ -251,14 +522,26 @@ export function apply(ctx, config) {
251
522
  ]
252
523
  },
253
524
  async execute (args, _exec) {
254
- const q = args.query ?? {};
525
+ const q = unwrapQuery(args, 'destination');
255
526
  if (!q.destination) throw new Error('gotry_hotel_search requires destination');
256
527
  const started = Date.now();
257
528
  const fallbackPath = join(import.meta.dirname, '..', '..', 'data', 'hotels_2026.json');
529
+ const anchor = buildTimeAnchor(new Date());
530
+ const dateNotes = [];
531
+ const resolveDate = (expr)=>{
532
+ if (!expr) return undefined;
533
+ const r = resolveSlotDate(expr, anchor);
534
+ if (!r.date) {
535
+ dateNotes.push(`日期未解析:${r.raw}——请向用户确认具体日期`);
536
+ return undefined;
537
+ }
538
+ if (r.raw !== r.date) dateNotes.push(`slot-resolved: ${r.raw} → ${r.date}`);
539
+ return r.date;
540
+ };
258
541
  const resp = await hbcliSearchHotels({
259
542
  destination: q.destination,
260
- checkIn: q.checkIn,
261
- checkOut: q.checkOut,
543
+ checkIn: resolveDate(q.checkIn),
544
+ checkOut: resolveDate(q.checkOut),
262
545
  adults: q.adults
263
546
  }, {
264
547
  hbcliBin: config.hbcliBin,
@@ -270,22 +553,40 @@ export function apply(ctx, config) {
270
553
  const evidence = isLive ? resp.evidence : '[静态包:估算]';
271
554
  await recordLatency(join(dir, 'bridge-latency.jsonl'), Date.now() - started, `hotel_search:${resp.via}`).catch(()=>{});
272
555
  const payload = {
556
+ ok: true,
273
557
  hotels: resp.hotels ?? null,
274
558
  evidence,
275
559
  destination: q.destination,
276
560
  via: resp.via,
277
561
  latency_ms: Date.now() - started,
278
562
  summary: resp.summary,
279
- error: resp.error
563
+ error: resp.error,
564
+ ...dateNotes.length ? {
565
+ date_notes: dateNotes
566
+ } : {}
280
567
  };
281
568
  return JSON.parse(JSON.stringify(payload));
282
569
  },
283
570
  presentCall: (args)=>({
284
571
  card: 'generic',
285
572
  title: `酒店搜索:${String(args.query?.destination ?? '')}`,
286
- kind: 'other',
573
+ kind: 'search',
287
574
  rawInput: args.query
288
- })
575
+ }),
576
+ presentResult: (_args, value)=>{
577
+ const r = value;
578
+ const n = Array.isArray(r.hotels) ? r.hotels.length : 0;
579
+ return {
580
+ card: 'generic',
581
+ title: `酒店:${r.destination ?? ''} ${n ? `${n} 家(${r.via === 'hbcli-realtime' ? '实时' : '静态包'})` : '无结果'}`,
582
+ content: [
583
+ {
584
+ type: 'text',
585
+ text: String(r.summary ?? '')
586
+ }
587
+ ]
588
+ };
589
+ }
289
590
  }));
290
591
  registerGuarded(defineTool({
291
592
  name: 'gotry_skeleton_check',
@@ -304,38 +605,27 @@ export function apply(ctx, config) {
304
605
  },
305
606
  output: {
306
607
  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
- }
608
+ type: 'json'
323
609
  },
324
610
  render: (_args, value)=>[
325
611
  {
326
612
  type: 'text',
327
- text: String(value.evidence ?? '')
613
+ text: String(value.evidence ?? JSON.stringify(value))
328
614
  }
329
615
  ]
330
616
  },
331
617
  async execute (args, _exec) {
332
- const verdict = await checkConnectivity(args.from, args.to);
333
- return JSON.parse(JSON.stringify(verdict));
618
+ const q = unwrapQuery(args);
619
+ const verdict = await checkConnectivity(String(q.from ?? ''), String(q.to ?? ''));
620
+ return JSON.parse(JSON.stringify({
621
+ ok: true,
622
+ ...verdict
623
+ }));
334
624
  },
335
625
  presentCall: (args)=>({
336
626
  card: 'generic',
337
627
  title: `骨架校验:${args.from}-${args.to}`,
338
- kind: 'other',
628
+ kind: 'execute',
339
629
  rawInput: args
340
630
  })
341
631
  }));
@@ -346,7 +636,7 @@ export function apply(ctx, config) {
346
636
  query: {
347
637
  type: 'json',
348
638
  required: true,
349
- description: '{ place: "大理市", month?: 8, mode?: "forecast"|"climate", days?: 7 }'
639
+ description: '{ place: "<城市名>", month?: 8, mode?: "forecast"|"climate", days?: 7 }'
350
640
  }
351
641
  },
352
642
  output: {
@@ -361,7 +651,7 @@ export function apply(ctx, config) {
361
651
  ]
362
652
  },
363
653
  async execute (args, _exec) {
364
- const q = args.query ?? {};
654
+ const q = unwrapQuery(args, 'place');
365
655
  const started = Date.now();
366
656
  let lat = q.lat, lng = q.lng;
367
657
  let placeLabel = q.place ?? `${q.lat},${q.lng}`;
@@ -407,9 +697,24 @@ export function apply(ctx, config) {
407
697
  presentCall: (args)=>({
408
698
  card: 'generic',
409
699
  title: `天气:${String(args.query?.place ?? '')}`,
410
- kind: 'other',
700
+ kind: 'fetch',
411
701
  rawInput: args.query
412
- })
702
+ }),
703
+ presentResult: (args, value)=>{
704
+ const r = value;
705
+ const place = String(args.query?.place ?? '');
706
+ const failed = String(r.summary ?? '').includes('降级') || String(r.summary ?? '').includes('unavailable');
707
+ return {
708
+ card: 'generic',
709
+ title: `天气:${place} ${failed ? '降级' : 'ok'}`,
710
+ content: [
711
+ {
712
+ type: 'text',
713
+ text: String(r.summary ?? '')
714
+ }
715
+ ]
716
+ };
717
+ }
413
718
  }));
414
719
  registerGuarded(defineTool({
415
720
  name: 'gotry_flight_verify',
@@ -433,7 +738,7 @@ export function apply(ctx, config) {
433
738
  ]
434
739
  },
435
740
  async execute (args, _exec) {
436
- const q = args.query ?? {};
741
+ const q = unwrapQuery(args, 'callsign');
437
742
  const started = Date.now();
438
743
  if (!q.callsign) {
439
744
  return JSON.parse(JSON.stringify({
@@ -451,6 +756,7 @@ export function apply(ctx, config) {
451
756
  await recordLatency(join(dir, 'bridge-latency.jsonl'), Date.now() - started, `flight_verify:${r.via}`).catch(()=>{});
452
757
  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
758
  return JSON.parse(JSON.stringify({
759
+ ok: true,
454
760
  verdict: r.verdict,
455
761
  callsign: r.callsign,
456
762
  airport: r.airport,
@@ -464,18 +770,145 @@ export function apply(ctx, config) {
464
770
  presentCall: (args)=>({
465
771
  card: 'generic',
466
772
  title: `飞行校验:${String(args.query?.callsign ?? '')}`,
467
- kind: 'other',
773
+ kind: 'fetch',
468
774
  rawInput: args.query
469
775
  })
470
776
  }));
777
+ registerGuarded(defineTool({
778
+ name: 'gotry_flyai_search',
779
+ description: 'Live flight/train search via Fliggy official FlyAI channel (read-only, no key, booking only via jumpUrl by the human). ' + 'PRIMARY source for real schedules & prices (RFC user-session-data-rfc). Input: kind=flight|train, from/to city names (Chinese), date YYYY-MM-DD. ' + 'Evidence [实时API:flyai@ts]. Cross-validate expensive claims against gotry_session_search when it matters.',
780
+ parameters: {
781
+ query: {
782
+ type: 'json',
783
+ required: true,
784
+ description: '{ kind: "flight"|"train", from: "上海", to: "丽江", date: "2026-10-01" }'
785
+ }
786
+ },
787
+ output: {
788
+ schema: {
789
+ type: 'json'
790
+ },
791
+ render: (_a, v)=>[
792
+ {
793
+ type: 'text',
794
+ text: String(v.summary ?? JSON.stringify(v).slice(0, 600))
795
+ }
796
+ ]
797
+ },
798
+ async execute (args, _exec) {
799
+ const q = unwrapQuery(args, 'from');
800
+ const kind = q.kind === 'train' ? 'train' : 'flight';
801
+ if (!q.from || !q.to || !q.date) {
802
+ return {
803
+ ok: false,
804
+ summary: '需要 from/to(中文城市名)与 date(YYYY-MM-DD)'
805
+ };
806
+ }
807
+ const r = await flyaiSearch({
808
+ kind,
809
+ origin: q.from,
810
+ destination: q.to,
811
+ depDate: q.date
812
+ });
813
+ 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}`);
814
+ const summary = r.verdict === 'hit' ? `${q.from}→${q.to} ${q.date} ${kind === 'flight' ? '机票' : '火车票'}(飞猪官方只读)前 ${top.length} 条:\n${top.join('\n')}\n${r.evidence}` : `${q.from}→${q.to} ${q.date} 无结果或失败:${r.error ?? 'miss'} ${r.evidence}`;
815
+ return JSON.parse(JSON.stringify({
816
+ ...r,
817
+ kind,
818
+ summary
819
+ }));
820
+ },
821
+ presentCall: (args)=>({
822
+ card: 'generic',
823
+ title: `官方检索:${String(args.query?.kind ?? 'flight')}`,
824
+ kind: 'fetch',
825
+ rawInput: args.query
826
+ }),
827
+ presentResult: (args, value)=>{
828
+ const r = value;
829
+ return {
830
+ card: 'generic',
831
+ title: `飞猪检索:${r.ok && (r.options?.length ?? 0) > 0 ? `${r.options.length} 条` : '降级'}`,
832
+ content: [
833
+ {
834
+ type: 'text',
835
+ text: String(value.summary ?? '')
836
+ }
837
+ ]
838
+ };
839
+ }
840
+ }));
841
+ registerGuarded(defineTool({
842
+ name: 'gotry_session_search',
843
+ description: 'Cross-validation search on the user\'s OWN logged-in browser session (dedicated persistent profile, ReadGuard = physically read-only: ' + 'write requests are aborted at network layer; agent NEVER touches credentials/captcha; on captcha it stops and returns challenged). ' + 'Currently ctrip-flight: sniffs the site search API for structured options. Evidence [会话:ctrip-flight@ts]. ' + 'verdict needs-login = run scripts/session-login.ts once with the human logging in. Rate-limited (≥30s between same-site calls). ' + 'Use to verify/double-check prices from gotry_flyai_search on the same itinerary (split by 直达/中转).',
844
+ parameters: {
845
+ query: {
846
+ type: 'json',
847
+ required: true,
848
+ description: '{ from: "上海", to: "丽江", date: "2026-10-01" }'
849
+ }
850
+ },
851
+ output: {
852
+ schema: {
853
+ type: 'json'
854
+ },
855
+ render: (_a, v)=>[
856
+ {
857
+ type: 'text',
858
+ text: String(v.summary ?? JSON.stringify(v).slice(0, 600))
859
+ }
860
+ ]
861
+ },
862
+ async execute (args, _exec) {
863
+ const q = unwrapQuery(args, 'from');
864
+ if (!q.from || !q.to || !q.date) {
865
+ return {
866
+ ok: false,
867
+ summary: '需要 from/to(中文城市名,词表内)与 date(YYYY-MM-DD)'
868
+ };
869
+ }
870
+ const r = await sessionFlightSearch({
871
+ from: q.from,
872
+ to: q.to,
873
+ date: q.date,
874
+ auditPath: join(config.stateRoot ?? '.', 'gotry-state', 'session-incidents.jsonl')
875
+ });
876
+ 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}`);
877
+ const summary = r.verdict === 'hit' ? `${q.from}→${q.to} ${q.date} 会话检索(携程,用户本人登录态)前 ${top.length} 条:\n${top.join('\n')}\n${r.evidence}` : `会话检索未取回(${r.verdict}):${r.error ?? ''} ${r.evidence}`;
878
+ return JSON.parse(JSON.stringify({
879
+ ...r,
880
+ summary
881
+ }));
882
+ },
883
+ presentCall: (args)=>({
884
+ card: 'generic',
885
+ title: `会话检索:${String(args.query?.from ?? '')}`,
886
+ kind: 'fetch',
887
+ rawInput: args.query
888
+ }),
889
+ presentResult: (_args, value)=>{
890
+ const r = value;
891
+ const label = r.verdict === 'hit' ? `会话 ${r.options.length} 条` : r.verdict === 'needs-login' ? '需登录' : r.verdict ?? '降级';
892
+ return {
893
+ card: 'generic',
894
+ title: `会话检索:${label}`,
895
+ content: [
896
+ {
897
+ type: 'text',
898
+ text: String(value.summary ?? '')
899
+ }
900
+ ]
901
+ };
902
+ }
903
+ }));
471
904
  registerGuarded(defineTool({
472
905
  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).',
906
+ 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
907
  parameters: {
475
908
  query: {
476
909
  type: 'json',
477
910
  required: true,
478
- description: '{ keyword: "大理", contentType?: "city"|"hotel", parentDestinationId?: "?", timeoutMs?: 12000 }'
911
+ description: '{ keyword: "<搜索关键词>", contentType?: "city"|"hotel", parentDestinationId?: "?", timeoutMs?: 12000 }'
479
912
  }
480
913
  },
481
914
  output: {
@@ -490,7 +923,7 @@ export function apply(ctx, config) {
490
923
  ]
491
924
  },
492
925
  async execute (args, _exec) {
493
- const q = args.query ?? {};
926
+ const q = unwrapQuery(args, 'keyword');
494
927
  const started = Date.now();
495
928
  if (!q.keyword) {
496
929
  return JSON.parse(JSON.stringify({
@@ -520,9 +953,23 @@ export function apply(ctx, config) {
520
953
  presentCall: (args)=>({
521
954
  card: 'generic',
522
955
  title: `Anything search:${String(args.query?.keyword ?? '')}`,
523
- kind: 'other',
956
+ kind: 'search',
524
957
  rawInput: args.query
525
- })
958
+ }),
959
+ presentResult: (_args, value)=>{
960
+ const r = value;
961
+ const n = Array.isArray(r.hits) ? r.hits.length : r.total_candidates ?? 0;
962
+ return {
963
+ card: 'generic',
964
+ title: `Anything:${r.keyword ?? ''} ${r.verdict === 'hit' ? `${n} hits` : r.verdict ?? 'no-result'}`,
965
+ content: [
966
+ {
967
+ type: 'text',
968
+ text: String(r.summary ?? '')
969
+ }
970
+ ]
971
+ };
972
+ }
526
973
  }));
527
974
  registerGuarded(defineTool({
528
975
  name: 'gotry_web_search',
@@ -546,7 +993,7 @@ export function apply(ctx, config) {
546
993
  ]
547
994
  },
548
995
  async execute (args, _exec) {
549
- const q = args.query ?? {};
996
+ const q = unwrapQuery(args, 'url');
550
997
  const started = Date.now();
551
998
  if (!q.url) {
552
999
  return JSON.parse(JSON.stringify({
@@ -576,7 +1023,7 @@ export function apply(ctx, config) {
576
1023
  presentCall: (args)=>({
577
1024
  card: 'generic',
578
1025
  title: `读网页:${String(args.query?.url ?? '')}`,
579
- kind: 'other',
1026
+ kind: 'fetch',
580
1027
  rawInput: args.query
581
1028
  })
582
1029
  }));
@@ -602,7 +1049,7 @@ export function apply(ctx, config) {
602
1049
  ]
603
1050
  },
604
1051
  async execute (args, _exec) {
605
- const q = args.query ?? {};
1052
+ const q = unwrapQuery(args, 'url');
606
1053
  if (!q.url) {
607
1054
  return JSON.parse(JSON.stringify({
608
1055
  ok: false,
@@ -627,7 +1074,7 @@ export function apply(ctx, config) {
627
1074
  presentCall: (args)=>({
628
1075
  card: 'generic',
629
1076
  title: `视频字幕:${String(args.query?.url ?? '')}`,
630
- kind: 'other',
1077
+ kind: 'fetch',
631
1078
  rawInput: args.query
632
1079
  })
633
1080
  }));
@@ -653,7 +1100,7 @@ export function apply(ctx, config) {
653
1100
  ]
654
1101
  },
655
1102
  async execute (args, _exec) {
656
- const q = args.query ?? {};
1103
+ const q = unwrapQuery(args, 'query');
657
1104
  if (!q.query) {
658
1105
  return JSON.parse(JSON.stringify({
659
1106
  ok: false,
@@ -678,13 +1125,13 @@ export function apply(ctx, config) {
678
1125
  presentCall: (args)=>({
679
1126
  card: 'generic',
680
1127
  title: `GitHub 搜索:${String(args.query?.query ?? '')}`,
681
- kind: 'other',
1128
+ kind: 'search',
682
1129
  rawInput: args.query
683
1130
  })
684
1131
  }));
685
1132
  registerGuarded(defineTool({
686
1133
  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].',
1134
+ 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
1135
  parameters: {
689
1136
  query: {
690
1137
  type: 'json',
@@ -704,7 +1151,7 @@ export function apply(ctx, config) {
704
1151
  ]
705
1152
  },
706
1153
  async execute (args, _exec) {
707
- const q = args.query ?? {};
1154
+ const q = unwrapQuery(args, 'channel');
708
1155
  const started = Date.now();
709
1156
  const dir = await ensureStateDir(config.stateRoot);
710
1157
  if (q.action === 'status' || !q.action && !q.channel) {
@@ -732,6 +1179,23 @@ export function apply(ctx, config) {
732
1179
  args: q.args,
733
1180
  timeoutMs: q.timeoutMs
734
1181
  });
1182
+ const dataStr = (v)=>{
1183
+ if (Array.isArray(v)) {
1184
+ const kept = [];
1185
+ let budget = 3500;
1186
+ for (const item of v){
1187
+ const s = JSON.stringify(item);
1188
+ if (budget - s.length < 0) break;
1189
+ budget -= s.length + 1;
1190
+ kept.push(item);
1191
+ }
1192
+ if (kept.length < v.length) kept.push(`…(截断:保留 ${kept.length}/${v.length} 条;可用上游方法带 limit 参数取更少)`);
1193
+ return kept;
1194
+ }
1195
+ if (typeof v === 'string') return v.length > 4000 ? v.slice(0, 4000) + '…(截断)' : v;
1196
+ return v;
1197
+ };
1198
+ if (r.ok) r.data = dataStr(r.data);
735
1199
  await recordLatency(join(dir, 'bridge-latency.jsonl'), Date.now() - started, `agent-reach:${q.channel}.${q.method}:${r.verdict}`).catch(()=>{});
736
1200
  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
1201
  return JSON.parse(JSON.stringify({
@@ -750,9 +1214,24 @@ export function apply(ctx, config) {
750
1214
  presentCall: (args)=>({
751
1215
  card: 'generic',
752
1216
  title: `Agent Reach:${String(args.query?.channel ?? '')}.${String(args.query?.method ?? 'status')}`,
753
- kind: 'other',
1217
+ kind: 'fetch',
754
1218
  rawInput: args.query
755
- })
1219
+ }),
1220
+ presentResult: (args, value)=>{
1221
+ const r = value;
1222
+ const q = args.query ?? {};
1223
+ const icon = r.verdict === 'found' ? '✅' : r.verdict === 'needs-setup' ? '🔧' : r.verdict === 'not-installed' ? '📦' : '❌';
1224
+ return {
1225
+ card: 'generic',
1226
+ title: `AgentReach ${icon} ${q.channel ?? ''}.${q.method ?? 'status'} ${r.verdict ?? ''}`,
1227
+ content: [
1228
+ {
1229
+ type: 'text',
1230
+ text: String(r.summary ?? '')
1231
+ }
1232
+ ]
1233
+ };
1234
+ }
756
1235
  }));
757
1236
  }
758
1237