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

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 (49) hide show
  1. package/README.md +1 -1
  2. package/bin/gotry-inner.js +73 -32
  3. package/cordis.gotry-patch.yml +2 -2
  4. package/data/flights_2026.json +215 -0
  5. package/data/golden_erhai.json +92 -0
  6. package/data/golden_trip_2026.json +75 -0
  7. package/data/hotels_2026.json +64 -0
  8. package/data/openflights-skeleton.json +895 -0
  9. package/data/yunnan-pack.json +201 -0
  10. package/dist/capabilities/agent-reach-deep.js +172 -0
  11. package/dist/capabilities/agent-reach.js +204 -0
  12. package/dist/capabilities/anything.js +139 -0
  13. package/dist/capabilities/hbcli.js +138 -0
  14. package/dist/capabilities/incident-log.js +99 -0
  15. package/dist/capabilities/opensky.js +74 -0
  16. package/dist/capabilities/weather.js +180 -0
  17. package/dist/scripts/agent-reach-deep-tests.js +54 -0
  18. package/dist/scripts/agent-reach-tests.js +38 -0
  19. package/dist/scripts/agent-reach-wrapper-tests.js +79 -0
  20. package/dist/scripts/anything-tests.js +95 -0
  21. package/dist/scripts/async-collect.js +18 -0
  22. package/dist/scripts/diff-test.js +39 -0
  23. package/dist/scripts/engine-run.js +14 -0
  24. package/dist/scripts/engine-tests.js +45 -0
  25. package/dist/scripts/hbcli-tests.js +85 -0
  26. package/dist/scripts/incident-tests.js +96 -0
  27. package/dist/scripts/journey-tests.js +45 -0
  28. package/dist/scripts/opensky-check.js +20 -0
  29. package/dist/scripts/opensky-tests.js +52 -0
  30. package/dist/scripts/probe-poi-tests.js +79 -0
  31. package/dist/scripts/replay-async.js +44 -0
  32. package/dist/scripts/replay-real.js +38 -0
  33. package/dist/scripts/replay.js +44 -0
  34. package/dist/scripts/skeleton-check.js +35 -0
  35. package/dist/scripts/skeleton-integration-test.js +22 -0
  36. package/dist/scripts/smoke.js +90 -0
  37. package/dist/scripts/unified-tests.js +49 -0
  38. package/dist/scripts/weather-tests.js +48 -0
  39. package/dist/src/bridge.js +34 -0
  40. package/dist/src/contracts.js +64 -0
  41. package/dist/src/dsh-llm.js +154 -0
  42. package/dist/src/engine.js +331 -0
  43. package/dist/src/index.js +760 -0
  44. package/dist/src/journey.js +147 -0
  45. package/dist/src/loop.js +296 -0
  46. package/dist/src/mock-llm.js +98 -0
  47. package/dist/src/model.js +134 -0
  48. package/dist/src/unified.js +536 -0
  49. package/package.json +3 -1
@@ -0,0 +1,760 @@
1
+ import { join } from 'node:path';
2
+ import z from '@deepseek-ai/schemastery';
3
+ import { defineTool } from '@deepseek-ai/dsh-tools';
4
+ import { ensureStateDir, readJson, recordLatency, writeJson } from './bridge.js';
5
+ import { segmentsFromCandidate, solveChoiceSegment } from './unified.js';
6
+ import { checkConnectivity } from '../scripts/skeleton-check.js';
7
+ import { parseCandidate, parseRequest } from './model.js';
8
+ import { searchHotels as hbcliSearchHotels } from '../capabilities/hbcli.js';
9
+ import { installProcessGuards, guardToolExecute } from '../capabilities/incident-log.js';
10
+ import { geocodePlace, getForecast, getClimate, wmoLabel } from '../capabilities/weather.js';
11
+ import { verifyFlight } from '../capabilities/opensky.js';
12
+ import { anythingSearch } from '../capabilities/anything.js';
13
+ import { readUrl, reach, reachStatus } from '../capabilities/agent-reach.js';
14
+ import { videoSubtitle, githubSearch } from '../capabilities/agent-reach-deep.js';
15
+ export const name = 'gotry-tools';
16
+ export const inject = [
17
+ 'tools',
18
+ 'systemPrompt'
19
+ ];
20
+ export const Config = z.object({
21
+ stateRoot: z.string().default('.'),
22
+ timeoutMs: z.number().default(30_000),
23
+ hbcliBin: z.string().default('hbcli')
24
+ });
25
+ export function apply(ctx, config) {
26
+ const sp = ctx['systemPrompt'];
27
+ sp?.variable?.('current_date', ()=>{
28
+ const d = new Date();
29
+ const ymd = `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`;
30
+ const weekdays = [
31
+ '日',
32
+ '一',
33
+ '二',
34
+ '三',
35
+ '四',
36
+ '五',
37
+ '六'
38
+ ];
39
+ return `${ymd} 周${weekdays[d.getDay()]}`;
40
+ });
41
+ installProcessGuards(config.stateRoot ?? '.', {
42
+ uncaughtException: 'gotry-tools',
43
+ unhandledRejection: 'gotry-tools'
44
+ });
45
+ const registerGuarded = (tool)=>{
46
+ const t = {
47
+ ...tool
48
+ };
49
+ if (typeof t.execute === 'function') {
50
+ t.execute = guardToolExecute(String(t.name), config.stateRoot ?? '.', t.execute);
51
+ }
52
+ ctx.tools.register(t);
53
+ };
54
+ registerGuarded(defineTool({
55
+ 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.',
57
+ parameters: {
58
+ payload: {
59
+ type: 'json',
60
+ required: true,
61
+ description: 'The full engine payload: { request, candidates }.'
62
+ }
63
+ },
64
+ output: {
65
+ schema: {
66
+ type: 'json'
67
+ },
68
+ render: (_args, value)=>[
69
+ {
70
+ type: 'text',
71
+ text: String(value.answer_md ?? JSON.stringify(value))
72
+ }
73
+ ]
74
+ },
75
+ async execute (args, _exec) {
76
+ const started = Date.now();
77
+ const payload = args.payload;
78
+ const req = parseRequest(payload['request']);
79
+ const cands = payload['candidates'].map(parseCandidate);
80
+ const spec = segmentsFromCandidate(req, cands);
81
+ const result = solveChoiceSegment(spec, req);
82
+ const dir = await ensureStateDir(config.stateRoot);
83
+ await recordLatency(join(dir, 'bridge-latency.jsonl'), Date.now() - started, 'feasibility_check:in-process-unified').catch(()=>{});
84
+ return {
85
+ ...result,
86
+ latency_ms: Date.now() - started,
87
+ via: 'in-process-unified'
88
+ };
89
+ },
90
+ presentCall: (args)=>({
91
+ card: 'generic',
92
+ title: 'GoTry 可行性检查(门到门全成本)',
93
+ kind: 'other',
94
+ rawInput: args.payload
95
+ })
96
+ }));
97
+ registerGuarded(defineTool({
98
+ 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).',
100
+ parameters: {
101
+ profile: {
102
+ type: 'json',
103
+ required: true,
104
+ description: '{ weights: {escape_rest: 0.7, ...}, evidence: [user quotes...], hard: {wake_not_before, min_arrival_energy_pct} }'
105
+ }
106
+ },
107
+ output: {
108
+ 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
+ }
122
+ },
123
+ render: (_args, value)=>[
124
+ {
125
+ type: 'text',
126
+ text: `动机画像已保存:${String(value.path ?? '')}`
127
+ }
128
+ ]
129
+ },
130
+ async execute (args, _exec) {
131
+ const profile = args.profile ?? {};
132
+ if (!profile.evidence?.length) {
133
+ throw new Error('refusing to save a motivation profile without evidence (P0 anti-fabrication rule)');
134
+ }
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 {
143
+ saved: true,
144
+ path,
145
+ profile: saved
146
+ };
147
+ },
148
+ presentCall: (args)=>({
149
+ card: 'generic',
150
+ title: '保存动机画像',
151
+ kind: 'other',
152
+ rawInput: args.profile
153
+ })
154
+ }));
155
+ registerGuarded(defineTool({
156
+ 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. 憧憬不被拒绝。',
158
+ parameters: {
159
+ entry: {
160
+ type: 'json',
161
+ required: true,
162
+ description: '{ name, reason, conditions: { days, budget_cny, best_months } }'
163
+ }
164
+ },
165
+ output: {
166
+ 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
+ }
180
+ },
181
+ render: (_args, value)=>{
182
+ const v = value;
183
+ return [
184
+ {
185
+ type: 'text',
186
+ text: `已加入「下一次出发」清单(共 ${v.total ?? '?'} 项):${v.path ?? ''}`
187
+ }
188
+ ];
189
+ }
190
+ },
191
+ async execute (args, _exec) {
192
+ 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)');
195
+ }
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
205
+ };
206
+ await writeJson(path, pool);
207
+ return {
208
+ added: false,
209
+ total: pool.length,
210
+ path
211
+ };
212
+ }
213
+ pool.push({
214
+ reason: '',
215
+ ...entry,
216
+ added_at: new Date().toISOString()
217
+ });
218
+ await writeJson(path, pool);
219
+ return {
220
+ added: true,
221
+ total: pool.length,
222
+ path
223
+ };
224
+ },
225
+ presentCall: (args)=>({
226
+ card: 'generic',
227
+ title: '加入「下一次出发」清单',
228
+ kind: 'other',
229
+ rawInput: args.entry
230
+ })
231
+ }));
232
+ registerGuarded(defineTool({
233
+ 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.',
235
+ parameters: {
236
+ query: {
237
+ type: 'json',
238
+ required: true,
239
+ description: '{ destination: "普吉", checkIn?: "2026-07-18", checkOut?: "2026-07-23", occupancy?: { adults: 2 } }'
240
+ }
241
+ },
242
+ output: {
243
+ schema: {
244
+ type: 'json'
245
+ },
246
+ render: (_args, value)=>[
247
+ {
248
+ type: 'text',
249
+ text: String(value.summary ?? JSON.stringify(value).slice(0, 400))
250
+ }
251
+ ]
252
+ },
253
+ async execute (args, _exec) {
254
+ const q = args.query ?? {};
255
+ if (!q.destination) throw new Error('gotry_hotel_search requires destination');
256
+ const started = Date.now();
257
+ const fallbackPath = join(import.meta.dirname, '..', '..', 'data', 'hotels_2026.json');
258
+ const resp = await hbcliSearchHotels({
259
+ destination: q.destination,
260
+ checkIn: q.checkIn,
261
+ checkOut: q.checkOut,
262
+ adults: q.adults
263
+ }, {
264
+ hbcliBin: config.hbcliBin,
265
+ timeoutMs: config.timeoutMs,
266
+ fallbackPath
267
+ });
268
+ const dir = await ensureStateDir(config.stateRoot);
269
+ const isLive = resp.via === 'hbcli-realtime';
270
+ const evidence = isLive ? resp.evidence : '[静态包:估算]';
271
+ await recordLatency(join(dir, 'bridge-latency.jsonl'), Date.now() - started, `hotel_search:${resp.via}`).catch(()=>{});
272
+ const payload = {
273
+ hotels: resp.hotels ?? null,
274
+ evidence,
275
+ destination: q.destination,
276
+ via: resp.via,
277
+ latency_ms: Date.now() - started,
278
+ summary: resp.summary,
279
+ error: resp.error
280
+ };
281
+ return JSON.parse(JSON.stringify(payload));
282
+ },
283
+ presentCall: (args)=>({
284
+ card: 'generic',
285
+ title: `酒店搜索:${String(args.query?.destination ?? '')}`,
286
+ kind: 'other',
287
+ rawInput: args.query
288
+ })
289
+ }));
290
+ registerGuarded(defineTool({
291
+ name: 'gotry_skeleton_check',
292
+ description: 'Check flight connectivity between two airports against the OpenFlights skeleton (free tier). ' + 'Three-valued: found = strong positive (airlines returned); hub-to-hub absence = downgrade signal, NEVER disproof ' + '(skeleton lags reality); outside hub set = no conclusion. Use BEFORE recommending a route.',
293
+ parameters: {
294
+ from: {
295
+ type: 'string',
296
+ required: true,
297
+ description: 'IATA, e.g. HKG'
298
+ },
299
+ to: {
300
+ type: 'string',
301
+ required: true,
302
+ description: 'IATA, e.g. HKT'
303
+ }
304
+ },
305
+ output: {
306
+ 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
+ }
323
+ },
324
+ render: (_args, value)=>[
325
+ {
326
+ type: 'text',
327
+ text: String(value.evidence ?? '')
328
+ }
329
+ ]
330
+ },
331
+ async execute (args, _exec) {
332
+ const verdict = await checkConnectivity(args.from, args.to);
333
+ return JSON.parse(JSON.stringify(verdict));
334
+ },
335
+ presentCall: (args)=>({
336
+ card: 'generic',
337
+ title: `骨架校验:${args.from}-${args.to}`,
338
+ kind: 'other',
339
+ rawInput: args
340
+ })
341
+ }));
342
+ registerGuarded(defineTool({
343
+ name: 'gotry_weather_check',
344
+ description: 'Check weather for a destination: forecast (≤16 days) or historical climate (seasonality baseline). ' + 'Free Open-Meteo API, no key required. Input: place name (Chinese ok) or lat/lng. ' + 'Returns daily temp range, precipitation probability, weather code — with evidence chain tagging ' + '[实时API:open-meteo@ts]. Use to ground seasonal advice in real data instead of LLM guessing.',
345
+ parameters: {
346
+ query: {
347
+ type: 'json',
348
+ required: true,
349
+ description: '{ place: "大理市", month?: 8, mode?: "forecast"|"climate", days?: 7 }'
350
+ }
351
+ },
352
+ output: {
353
+ schema: {
354
+ type: 'json'
355
+ },
356
+ render: (_args, value)=>[
357
+ {
358
+ type: 'text',
359
+ text: String(value.summary ?? JSON.stringify(value).slice(0, 600))
360
+ }
361
+ ]
362
+ },
363
+ async execute (args, _exec) {
364
+ const q = args.query ?? {};
365
+ const started = Date.now();
366
+ let lat = q.lat, lng = q.lng;
367
+ let placeLabel = q.place ?? `${q.lat},${q.lng}`;
368
+ if (lat === undefined || lng === undefined) {
369
+ if (!q.place) throw new Error('gotry_weather_check requires place name or lat/lng');
370
+ const geo = await geocodePlace(q.place);
371
+ if (!geo.ok || geo.results.length === 0) {
372
+ return JSON.parse(JSON.stringify({
373
+ ok: false,
374
+ summary: `地点「${q.place}」地理编码失败:${geo.error ?? '无结果'}`,
375
+ evidence: geo.evidence
376
+ }));
377
+ }
378
+ const hit = geo.results[0];
379
+ lat = hit.latitude;
380
+ lng = hit.longitude;
381
+ placeLabel = `${hit.name}(${hit.admin1 ?? hit.country ?? ''})`;
382
+ }
383
+ const isClimate = q.mode === 'climate' || q.month !== undefined && q.mode !== 'forecast';
384
+ const r = isClimate ? await getClimate({
385
+ latitude: lat,
386
+ longitude: lng
387
+ }, q.month ?? new Date().getMonth() + 1) : await getForecast({
388
+ latitude: lat,
389
+ longitude: lng
390
+ }, {
391
+ days: q.days
392
+ });
393
+ const dir = await ensureStateDir(config.stateRoot);
394
+ await recordLatency(join(dir, 'bridge-latency.jsonl'), Date.now() - started, `weather:${r.via}`).catch(()=>{});
395
+ const dailyLines = (r.daily ?? []).slice(0, 7).map((d)=>`${d.date} ${d.tempMinC.toFixed(0)}–${d.tempMaxC.toFixed(0)}°C ${wmoLabel(d.weatherCode)}${d.precipProbMaxPct !== null ? ` 降水概率${d.precipProbMaxPct}%` : ''}`);
396
+ const summary = r.ok ? `${placeLabel}:${isClimate ? '历史气候' : `${q.days ?? 7} 天预报`}\n${dailyLines.join('\n')}\n${r.evidence}` : `${placeLabel}:天气查询失败(${r.error})${r.evidence}`;
397
+ return JSON.parse(JSON.stringify({
398
+ ok: r.ok,
399
+ place: placeLabel,
400
+ mode: isClimate ? 'climate' : 'forecast',
401
+ daily: r.daily,
402
+ evidence: r.evidence,
403
+ summary,
404
+ latency_ms: Date.now() - started
405
+ }));
406
+ },
407
+ presentCall: (args)=>({
408
+ card: 'generic',
409
+ title: `天气:${String(args.query?.place ?? '')}`,
410
+ kind: 'other',
411
+ rawInput: args.query
412
+ })
413
+ }));
414
+ registerGuarded(defineTool({
415
+ name: 'gotry_flight_verify',
416
+ description: 'Verify whether a flight callsign is currently observable on the OpenSky ADS-B network. ' + 'Free anonymous API (~400 credits/day, 4 req/s burst). Three-valued semantics: ' + 'observed = strong positive (the aircraft is currently being broadcast); ' + 'not_observed = no conclusion (ADS-B coverage is limited by geography/altitude — ' + 'a missing signal does NOT disprove the flight); ' + 'unavailable = API failure, gracefully degraded. ' + 'Use to ground "is this flight actually flying right now?" in real data, complementing ' + 'the OpenFlights skeleton (historical connectivity) and the static flight pack (planned schedule).',
417
+ parameters: {
418
+ query: {
419
+ type: 'json',
420
+ required: true,
421
+ description: '{ callsign: "EK329", airport?: "OMDB", timeoutMs?: 10000 }'
422
+ }
423
+ },
424
+ output: {
425
+ schema: {
426
+ type: 'json'
427
+ },
428
+ render: (_args, value)=>[
429
+ {
430
+ type: 'text',
431
+ text: String(value.summary ?? JSON.stringify(value).slice(0, 500))
432
+ }
433
+ ]
434
+ },
435
+ async execute (args, _exec) {
436
+ const q = args.query ?? {};
437
+ const started = Date.now();
438
+ if (!q.callsign) {
439
+ return JSON.parse(JSON.stringify({
440
+ verdict: 'unavailable',
441
+ evidence: '[校验不可用:无 callsign]',
442
+ summary: 'callsign 必填'
443
+ }));
444
+ }
445
+ const r = await verifyFlight({
446
+ callsign: q.callsign,
447
+ airport: q.airport,
448
+ timeoutMs: q.timeoutMs
449
+ });
450
+ const dir = await ensureStateDir(config.stateRoot);
451
+ await recordLatency(join(dir, 'bridge-latency.jsonl'), Date.now() - started, `flight_verify:${r.via}`).catch(()=>{});
452
+ 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
+ return JSON.parse(JSON.stringify({
454
+ verdict: r.verdict,
455
+ callsign: r.callsign,
456
+ airport: r.airport,
457
+ sample_size: r.sampleSize,
458
+ hits: r.hits,
459
+ evidence: r.evidence,
460
+ summary,
461
+ latency_ms: Date.now() - started
462
+ }));
463
+ },
464
+ presentCall: (args)=>({
465
+ card: 'generic',
466
+ title: `飞行校验:${String(args.query?.callsign ?? '')}`,
467
+ kind: 'other',
468
+ rawInput: args.query
469
+ })
470
+ }));
471
+ registerGuarded(defineTool({
472
+ 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).',
474
+ parameters: {
475
+ query: {
476
+ type: 'json',
477
+ required: true,
478
+ description: '{ keyword: "大理", contentType?: "city"|"hotel", parentDestinationId?: "?", timeoutMs?: 12000 }'
479
+ }
480
+ },
481
+ output: {
482
+ schema: {
483
+ type: 'json'
484
+ },
485
+ render: (_args, value)=>[
486
+ {
487
+ type: 'text',
488
+ text: String(value.summary ?? JSON.stringify(value).slice(0, 800))
489
+ }
490
+ ]
491
+ },
492
+ async execute (args, _exec) {
493
+ const q = args.query ?? {};
494
+ const started = Date.now();
495
+ if (!q.keyword) {
496
+ return JSON.parse(JSON.stringify({
497
+ ok: false,
498
+ verdict: 'error',
499
+ summary: 'keyword 必填',
500
+ evidence: '[hbcli-anything@error] empty'
501
+ }));
502
+ }
503
+ const r = await anythingSearch(q);
504
+ const dir = await ensureStateDir(config.stateRoot);
505
+ await recordLatency(join(dir, 'bridge-latency.jsonl'), Date.now() - started, `anything:${r.via}`).catch(()=>{});
506
+ const top5 = (r.hits ?? []).slice(0, 5);
507
+ const summary = r.verdict === 'hit' ? `${q.keyword} → hit (${r.hits?.length ?? 0} 候选项)\n${top5.map((h, i)=>` ${i + 1}. [${h.type}] ${h.name}${h.latitude !== undefined && h.longitude !== undefined ? ` @ (${h.latitude.toFixed(3)},${h.longitude.toFixed(3)})` : ''}`).join('\n')}\n${r.evidence}` : r.verdict === 'miss' ? `${q.keyword} → miss (酒店-be 一切正常但无候选)\n${r.evidence}` : `${q.keyword} → unavailable (${r.error})\n${r.evidence}`;
508
+ return JSON.parse(JSON.stringify({
509
+ ok: r.ok,
510
+ verdict: r.verdict,
511
+ keyword: q.keyword,
512
+ content_type: q.contentType ?? null,
513
+ total_candidates: r.totalCandidates,
514
+ hits: r.hits,
515
+ evidence: r.evidence,
516
+ summary,
517
+ latency_ms: Date.now() - started
518
+ }));
519
+ },
520
+ presentCall: (args)=>({
521
+ card: 'generic',
522
+ title: `Anything search:${String(args.query?.keyword ?? '')}`,
523
+ kind: 'other',
524
+ rawInput: args.query
525
+ })
526
+ }));
527
+ registerGuarded(defineTool({
528
+ name: 'gotry_web_search',
529
+ description: 'Read any public URL as markdown (Jina Reader, free, no key). ' + 'Use as the "last mile" web reader when hotel-be Anything or gotry tools lack the answer. ' + 'NOT a general-purpose search engine — only fetches a URL you already know. ' + 'Three-valued: ok / error(非法 URL/超时)/not-reachable(r.jina.ai 不可用).' + 'Contract with gotry capabilities/anything.ts: 同构(L4 证据链 + 降级不阻塞 + 三值)。',
530
+ parameters: {
531
+ query: {
532
+ type: 'json',
533
+ required: true,
534
+ description: '{ url: "https://example.com", timeoutMs?: 20000 }'
535
+ }
536
+ },
537
+ output: {
538
+ schema: {
539
+ type: 'json'
540
+ },
541
+ render: (_args, value)=>[
542
+ {
543
+ type: 'text',
544
+ text: String(value.content?.slice(0, 800) ?? JSON.stringify(value).slice(0, 800))
545
+ }
546
+ ]
547
+ },
548
+ async execute (args, _exec) {
549
+ const q = args.query ?? {};
550
+ const started = Date.now();
551
+ if (!q.url) {
552
+ return JSON.parse(JSON.stringify({
553
+ ok: false,
554
+ summary: 'url 必填',
555
+ evidence: '[agent-reach:error] empty url'
556
+ }));
557
+ }
558
+ const r = await readUrl({
559
+ url: q.url,
560
+ timeoutMs: q.timeoutMs
561
+ });
562
+ const dir = await ensureStateDir(config.stateRoot);
563
+ await recordLatency(join(dir, 'bridge-latency.jsonl'), Date.now() - started, `agent-reach:${r.via}`).catch(()=>{});
564
+ const summary = r.ok ? `${q.url} → ${r.title ?? '(no title)'} (${r.latencyMs}ms)\n${r.evidence}\n---\n${r.content?.slice(0, 600) ?? ''}` : `${q.url} → unavailable (${r.error})\n${r.evidence}`;
565
+ return JSON.parse(JSON.stringify({
566
+ ok: r.ok,
567
+ url: q.url,
568
+ via: r.via,
569
+ title: r.title,
570
+ content: r.content,
571
+ evidence: r.evidence,
572
+ summary,
573
+ latency_ms: Date.now() - started
574
+ }));
575
+ },
576
+ presentCall: (args)=>({
577
+ card: 'generic',
578
+ title: `读网页:${String(args.query?.url ?? '')}`,
579
+ kind: 'other',
580
+ rawInput: args.query
581
+ })
582
+ }));
583
+ registerGuarded(defineTool({
584
+ name: 'gotry_video_subtitle',
585
+ description: 'Extract subtitles from a YouTube/Bilibili video (yt-dlp, optional tool). ' + 'If yt-dlp is installed on this machine, returns the subtitle text (vtt, zh-Hans/zh/en preference). ' + 'If NOT installed, degrades gracefully with install instructions — never blocks. ' + 'Evidence chain: [agent-reach:yt-dlp@ts] / [@not-installed@ts].',
586
+ parameters: {
587
+ query: {
588
+ type: 'json',
589
+ required: true,
590
+ description: '{ url: "https://www.youtube.com/watch?v=...", lang?: "zh-Hans,zh,en" }'
591
+ }
592
+ },
593
+ output: {
594
+ schema: {
595
+ type: 'json'
596
+ },
597
+ render: (_args, value)=>[
598
+ {
599
+ type: 'text',
600
+ text: String(value.summary ?? JSON.stringify(value).slice(0, 600))
601
+ }
602
+ ]
603
+ },
604
+ async execute (args, _exec) {
605
+ const q = args.query ?? {};
606
+ if (!q.url) {
607
+ return JSON.parse(JSON.stringify({
608
+ ok: false,
609
+ summary: 'url 必填'
610
+ }));
611
+ }
612
+ const r = await videoSubtitle({
613
+ url: q.url,
614
+ lang: q.lang
615
+ });
616
+ const summary = r.verdict === 'found' ? `${q.url} 字幕提取成功 (${r.latencyMs}ms)\n${r.evidence}\n---\n${(r.subtitles ?? '').slice(0, 800)}` : r.verdict === 'not-installed' ? `yt-dlp 未安装。${r.stderr}\n${r.evidence}` : `${q.url} 字幕提取失败(${r.verdict})\n${r.evidence}`;
617
+ return JSON.parse(JSON.stringify({
618
+ ok: r.ok,
619
+ verdict: r.verdict,
620
+ url: q.url,
621
+ subtitles: r.subtitles?.slice(0, 4000),
622
+ evidence: r.evidence,
623
+ summary,
624
+ latency_ms: r.latencyMs
625
+ }));
626
+ },
627
+ presentCall: (args)=>({
628
+ card: 'generic',
629
+ title: `视频字幕:${String(args.query?.url ?? '')}`,
630
+ kind: 'other',
631
+ rawInput: args.query
632
+ })
633
+ }));
634
+ registerGuarded(defineTool({
635
+ name: 'gotry_github_search',
636
+ description: 'Search GitHub repositories (gh CLI, optional tool). ' + 'If gh is installed and authenticated, returns repos with name/description/stars/url. ' + 'If NOT installed, degrades with install instructions — never blocks. ' + 'Evidence chain: [agent-reach:gh@ts] / [@not-installed@ts].',
637
+ parameters: {
638
+ query: {
639
+ type: 'json',
640
+ required: true,
641
+ description: '{ query: "agent-reach", limit?: 5 }'
642
+ }
643
+ },
644
+ output: {
645
+ schema: {
646
+ type: 'json'
647
+ },
648
+ render: (_args, value)=>[
649
+ {
650
+ type: 'text',
651
+ text: String(value.summary ?? JSON.stringify(value).slice(0, 600))
652
+ }
653
+ ]
654
+ },
655
+ async execute (args, _exec) {
656
+ const q = args.query ?? {};
657
+ if (!q.query) {
658
+ return JSON.parse(JSON.stringify({
659
+ ok: false,
660
+ summary: 'query 必填'
661
+ }));
662
+ }
663
+ const r = await githubSearch({
664
+ query: q.query,
665
+ limit: q.limit
666
+ });
667
+ const summary = r.verdict === 'found' ? `${q.query} → ${r.repos?.length ?? 0} repos\n${(r.repos ?? []).map((x, i)=>` ${i + 1}. ${x.name} ★${x.stars ?? '?'} — ${(x.description ?? '').slice(0, 60)}`).join('\n')}\n${r.evidence}` : r.verdict === 'not-installed' ? `gh 未安装。${r.stderr}\n${r.evidence}` : `${q.query} 搜索失败(${r.verdict})\n${r.evidence}`;
668
+ return JSON.parse(JSON.stringify({
669
+ ok: r.ok,
670
+ verdict: r.verdict,
671
+ query: q.query,
672
+ repos: r.repos,
673
+ evidence: r.evidence,
674
+ summary,
675
+ latency_ms: r.latencyMs
676
+ }));
677
+ },
678
+ presentCall: (args)=>({
679
+ card: 'generic',
680
+ title: `GitHub 搜索:${String(args.query?.query ?? '')}`,
681
+ kind: 'other',
682
+ rawInput: args.query
683
+ })
684
+ }));
685
+ registerGuarded(defineTool({
686
+ 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].',
688
+ parameters: {
689
+ query: {
690
+ type: 'json',
691
+ required: true,
692
+ description: '{ action: "status" } 或 { action: "reach", channel: "<上游渠道名,如 web/v2ex/xueqiu>", method: "<上游方法名,如 read/get_hot_topics/get_stock_quote>", args?: "<空格分隔参数>" }'
693
+ }
694
+ },
695
+ output: {
696
+ schema: {
697
+ type: 'json'
698
+ },
699
+ render: (_args, value)=>[
700
+ {
701
+ type: 'text',
702
+ text: String(value.summary ?? JSON.stringify(value).slice(0, 800))
703
+ }
704
+ ]
705
+ },
706
+ async execute (args, _exec) {
707
+ const q = args.query ?? {};
708
+ const started = Date.now();
709
+ const dir = await ensureStateDir(config.stateRoot);
710
+ if (q.action === 'status' || !q.action && !q.channel) {
711
+ const st = await reachStatus(q.timeoutMs);
712
+ await recordLatency(join(dir, 'bridge-latency.jsonl'), Date.now() - started, 'agent-reach:doctor').catch(()=>{});
713
+ const summary = st.via === 'agent-reach-cli' ? `Agent Reach doctor(上游 CLI,原样透传):\n${st.output}\n${st.evidence}` : `Agent Reach 未装:\n${st.output}\n${st.evidence}`;
714
+ return JSON.parse(JSON.stringify({
715
+ ok: st.ok,
716
+ via: st.via,
717
+ output: st.output,
718
+ evidence: st.evidence,
719
+ summary,
720
+ latency_ms: Date.now() - started
721
+ }));
722
+ }
723
+ if (!q.channel || !q.method) {
724
+ return JSON.parse(JSON.stringify({
725
+ ok: false,
726
+ summary: 'channel 与 method 必填(或 action=status);清单可先随便调一次,inventory 会带回上游渠道/方法表'
727
+ }));
728
+ }
729
+ const r = await reach({
730
+ channel: q.channel,
731
+ method: q.method,
732
+ args: q.args,
733
+ timeoutMs: q.timeoutMs
734
+ });
735
+ await recordLatency(join(dir, 'bridge-latency.jsonl'), Date.now() - started, `agent-reach:${q.channel}.${q.method}:${r.verdict}`).catch(()=>{});
736
+ 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
+ return JSON.parse(JSON.stringify({
738
+ ok: r.ok,
739
+ channel: r.channel,
740
+ method: q.method,
741
+ verdict: r.verdict,
742
+ data: typeof r.data === 'string' ? r.data.slice(0, 4000) : r.data,
743
+ inventory: r.inventory,
744
+ setup: r.setup,
745
+ evidence: r.evidence,
746
+ summary,
747
+ latency_ms: Date.now() - started
748
+ }));
749
+ },
750
+ presentCall: (args)=>({
751
+ card: 'generic',
752
+ title: `Agent Reach:${String(args.query?.channel ?? '')}.${String(args.query?.method ?? 'status')}`,
753
+ kind: 'other',
754
+ rawInput: args.query
755
+ })
756
+ }));
757
+ }
758
+
759
+
760
+ //# sourceURL=/Users/bytedance/work/gotry/ts/src/index.ts