@danceiny/gotry 0.0.1-rc.10

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 (130) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +267 -0
  3. package/bin/gotry-inner.js +214 -0
  4. package/bin/gotry-stdio-ask.js +59 -0
  5. package/bin/gotry.js +28 -0
  6. package/cordis.gotry-patch.yml +86 -0
  7. package/data/flights_2026.json +218 -0
  8. package/data/golden_erhai.json +92 -0
  9. package/data/golden_trip_2026.json +75 -0
  10. package/data/hotels_2026.json +151 -0
  11. package/data/openflights-skeleton.json +895 -0
  12. package/data/time-slot-eval.json +384 -0
  13. package/data/yunnan-pack.json +202 -0
  14. package/dist/capabilities/agent-reach-bridge.py +90 -0
  15. package/dist/capabilities/agent-reach-deep.js +172 -0
  16. package/dist/capabilities/agent-reach.js +204 -0
  17. package/dist/capabilities/anything.js +139 -0
  18. package/dist/capabilities/flyai.js +131 -0
  19. package/dist/capabilities/hbcli.js +143 -0
  20. package/dist/capabilities/incident-log.js +100 -0
  21. package/dist/capabilities/opensky.js +74 -0
  22. package/dist/capabilities/session/action-cache.js +120 -0
  23. package/dist/capabilities/session/adapters/ctrip-flight.js +83 -0
  24. package/dist/capabilities/session/adapters/meituan-local.js +73 -0
  25. package/dist/capabilities/session/extract.js +40 -0
  26. package/dist/capabilities/session/read-guard.js +99 -0
  27. package/dist/capabilities/session/transport.js +101 -0
  28. package/dist/capabilities/session-search.js +93 -0
  29. package/dist/capabilities/weather.js +180 -0
  30. package/dist/scripts/action-cache-tests.js +107 -0
  31. package/dist/scripts/agent-reach-deep-tests.js +54 -0
  32. package/dist/scripts/agent-reach-tests.js +38 -0
  33. package/dist/scripts/agent-reach-wrapper-tests.js +79 -0
  34. package/dist/scripts/anything-tests.js +95 -0
  35. package/dist/scripts/async-collect.js +37 -0
  36. package/dist/scripts/companion-tests.js +112 -0
  37. package/dist/scripts/diff-test.js +39 -0
  38. package/dist/scripts/engine-run.js +14 -0
  39. package/dist/scripts/engine-tests.js +45 -0
  40. package/dist/scripts/hbcli-tests.js +101 -0
  41. package/dist/scripts/incident-tests.js +96 -0
  42. package/dist/scripts/journey-tests.js +45 -0
  43. package/dist/scripts/ledger-tests.js +365 -0
  44. package/dist/scripts/ledger-workflow-crash.js +42 -0
  45. package/dist/scripts/memory-capture-tests.js +77 -0
  46. package/dist/scripts/memory-decay-tests.js +83 -0
  47. package/dist/scripts/memory-metrics.js +24 -0
  48. package/dist/scripts/nudge-digest.js +83 -0
  49. package/dist/scripts/opensky-check.js +20 -0
  50. package/dist/scripts/opensky-tests.js +52 -0
  51. package/dist/scripts/probe-poi-tests.js +88 -0
  52. package/dist/scripts/publish-preverify.js +68 -0
  53. package/dist/scripts/replay-async.js +44 -0
  54. package/dist/scripts/replay-real.js +38 -0
  55. package/dist/scripts/replay.js +116 -0
  56. package/dist/scripts/session-attach-diagnose.js +27 -0
  57. package/dist/scripts/session-attach-poc.js +65 -0
  58. package/dist/scripts/session-extract-tests.js +81 -0
  59. package/dist/scripts/session-tests.js +162 -0
  60. package/dist/scripts/skeleton-check.js +37 -0
  61. package/dist/scripts/skeleton-integration-test.js +22 -0
  62. package/dist/scripts/skills-contract-tests.js +85 -0
  63. package/dist/scripts/smoke.js +328 -0
  64. package/dist/scripts/state-cli-tests.js +136 -0
  65. package/dist/scripts/state-cli.js +248 -0
  66. package/dist/scripts/time-eval-tests.js +318 -0
  67. package/dist/scripts/travel-timeline-tests.js +123 -0
  68. package/dist/scripts/unified-tests.js +49 -0
  69. package/dist/scripts/weather-tests.js +48 -0
  70. package/dist/src/bridge.js +34 -0
  71. package/dist/src/companions.js +112 -0
  72. package/dist/src/contracts.js +64 -0
  73. package/dist/src/dsh-llm.js +174 -0
  74. package/dist/src/engine.js +331 -0
  75. package/dist/src/index.js +1239 -0
  76. package/dist/src/journey.js +147 -0
  77. package/dist/src/loop.js +360 -0
  78. package/dist/src/memory-capture.js +40 -0
  79. package/dist/src/memory-decay.js +31 -0
  80. package/dist/src/memory-utility.js +55 -0
  81. package/dist/src/mock-llm.js +103 -0
  82. package/dist/src/model.js +134 -0
  83. package/dist/src/slot-spec.js +165 -0
  84. package/dist/src/state-ledger.js +901 -0
  85. package/dist/src/time-anchor.js +100 -0
  86. package/dist/src/tool-packet.js +12 -0
  87. package/dist/src/travel-slots.js +144 -0
  88. package/dist/src/travel-timeline.js +78 -0
  89. package/dist/src/unified.js +538 -0
  90. package/dist/src/wish-pool.js +27 -0
  91. package/package.json +84 -0
  92. package/ts/capabilities/agent-reach-deep.ts +114 -0
  93. package/ts/capabilities/agent-reach.ts +158 -0
  94. package/ts/capabilities/anything.ts +201 -0
  95. package/ts/capabilities/flyai.ts +151 -0
  96. package/ts/capabilities/hbcli.ts +152 -0
  97. package/ts/capabilities/incident-log.ts +148 -0
  98. package/ts/capabilities/opensky.ts +121 -0
  99. package/ts/capabilities/session/action-cache.ts +148 -0
  100. package/ts/capabilities/session/adapters/ctrip-flight.ts +107 -0
  101. package/ts/capabilities/session/adapters/meituan-local.ts +70 -0
  102. package/ts/capabilities/session/extract.ts +54 -0
  103. package/ts/capabilities/session/read-guard.ts +141 -0
  104. package/ts/capabilities/session/transport.ts +114 -0
  105. package/ts/capabilities/session-search.ts +129 -0
  106. package/ts/capabilities/weather.ts +176 -0
  107. package/ts/package.json +27 -0
  108. package/ts/scripts/async-collect.ts +50 -0
  109. package/ts/scripts/skeleton-check.ts +49 -0
  110. package/ts/scripts/skeleton-integration-test.ts +23 -0
  111. package/ts/scripts/state-cli.ts +201 -0
  112. package/ts/src/bridge.ts +44 -0
  113. package/ts/src/companions.ts +113 -0
  114. package/ts/src/contracts.ts +165 -0
  115. package/ts/src/dsh-llm.ts +155 -0
  116. package/ts/src/index.ts +968 -0
  117. package/ts/src/loop.ts +411 -0
  118. package/ts/src/memory-capture.ts +59 -0
  119. package/ts/src/memory-decay.ts +55 -0
  120. package/ts/src/memory-utility.ts +70 -0
  121. package/ts/src/mock-llm.ts +100 -0
  122. package/ts/src/model.ts +220 -0
  123. package/ts/src/slot-spec.ts +172 -0
  124. package/ts/src/state-ledger.ts +849 -0
  125. package/ts/src/time-anchor.ts +134 -0
  126. package/ts/src/tool-packet.ts +32 -0
  127. package/ts/src/travel-slots.ts +219 -0
  128. package/ts/src/travel-timeline.ts +83 -0
  129. package/ts/src/unified.ts +582 -0
  130. package/ts/src/wish-pool.ts +66 -0
@@ -0,0 +1,1239 @@
1
+ import { join } from 'node:path';
2
+ import z from '@deepseek-ai/schemastery';
3
+ import { defineTool } from '@deepseek-ai/dsh-tools';
4
+ import { ensureStateDir, recordLatency } 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 { 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';
17
+ import { geocodePlace, getForecast, getClimate, wmoLabel } from '../capabilities/weather.js';
18
+ import { verifyFlight } from '../capabilities/opensky.js';
19
+ import { anythingSearch } from '../capabilities/anything.js';
20
+ import { readUrl, reach, reachStatus } from '../capabilities/agent-reach.js';
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';
24
+ export const name = 'gotry-tools';
25
+ export const inject = [
26
+ 'tools',
27
+ 'systemPrompt'
28
+ ];
29
+ export const Config = z.object({
30
+ stateRoot: z.string().default('.'),
31
+ timeoutMs: z.number().default(30_000),
32
+ hbcliBin: z.string().default('hbcli')
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
+ }
76
+ export function apply(ctx, config) {
77
+ const sp = ctx['systemPrompt'];
78
+ sp?.variable?.('current_date', ()=>{
79
+ const d = new Date();
80
+ const ymd = `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`;
81
+ const weekdays = [
82
+ '日',
83
+ '一',
84
+ '二',
85
+ '三',
86
+ '四',
87
+ '五',
88
+ '六'
89
+ ];
90
+ return `${ymd} 周${weekdays[d.getDay()]}`;
91
+ });
92
+ sp?.variable?.('time_anchor_card', ()=>buildTimeAnchor(new Date()).card);
93
+ sp?.variable?.('motivation_brief', ()=>renderMotivationBrief(config.stateRoot ?? '.'));
94
+ installProcessGuards(config.stateRoot ?? '.', {
95
+ uncaughtException: 'gotry-tools',
96
+ unhandledRejection: 'gotry-tools'
97
+ });
98
+ const registerGuarded = (tool)=>{
99
+ const t = {
100
+ ...tool
101
+ };
102
+ if (typeof t.execute === 'function') {
103
+ t.execute = guardToolExecute(String(t.name), config.stateRoot ?? '.', t.execute);
104
+ }
105
+ ctx.tools.register(t);
106
+ };
107
+ registerGuarded(defineTool({
108
+ name: 'gotry_feasibility_check',
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.',
110
+ parameters: {
111
+ payload: {
112
+ type: 'json',
113
+ required: true,
114
+ description: 'The full engine payload: { request, candidates }.'
115
+ }
116
+ },
117
+ output: {
118
+ schema: {
119
+ type: 'json'
120
+ },
121
+ render: (_args, value)=>[
122
+ {
123
+ type: 'text',
124
+ text: String(value.answer_md ?? JSON.stringify(value))
125
+ }
126
+ ]
127
+ },
128
+ async execute (args, _exec) {
129
+ const started = Date.now();
130
+ const payload = args.payload;
131
+ const req = parseRequest(payload['request']);
132
+ const cands = payload['candidates'].map(parseCandidate);
133
+ const spec = segmentsFromCandidate(req, cands);
134
+ const result = solveChoiceSegment(spec, req);
135
+ const dir = await ensureStateDir(config.stateRoot);
136
+ await recordLatency(join(dir, 'bridge-latency.jsonl'), Date.now() - started, 'feasibility_check:in-process-unified').catch(()=>{});
137
+ return {
138
+ ok: true,
139
+ ...result,
140
+ latency_ms: Date.now() - started,
141
+ via: 'in-process-unified'
142
+ };
143
+ },
144
+ presentCall: (args)=>({
145
+ card: 'generic',
146
+ title: 'GoTry 可行性检查(门到门全成本)',
147
+ kind: 'execute',
148
+ rawInput: args.payload
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
+ }
169
+ }));
170
+ registerGuarded(defineTool({
171
+ name: 'gotry_motivation_save',
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).',
173
+ parameters: {
174
+ profile: {
175
+ type: 'json',
176
+ required: true,
177
+ description: '{ weights: {escape_rest: 0.7, ...}, evidence: [user quotes...], hard: {wake_not_before, min_arrival_energy_pct} }'
178
+ }
179
+ },
180
+ output: {
181
+ schema: {
182
+ type: 'json'
183
+ },
184
+ render: (_args, value)=>[
185
+ {
186
+ type: 'text',
187
+ text: `动机画像已保存:${String(value.path ?? '')}`
188
+ }
189
+ ]
190
+ },
191
+ async execute (args, _exec) {
192
+ const incoming = args.profile ?? {};
193
+ if (!incoming.evidence?.length) {
194
+ throw new Error('refusing to save a motivation profile without evidence (P0 anti-fabrication rule)');
195
+ }
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,
205
+ saved: true,
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: '无新内容(幂等跳过)'
215
+ };
216
+ },
217
+ presentCall: (args)=>({
218
+ card: 'generic',
219
+ title: '保存动机画像',
220
+ kind: 'edit',
221
+ rawInput: args.profile
222
+ })
223
+ }));
224
+ registerGuarded(defineTool({
225
+ name: 'gotry_wish_pool_add',
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 (永不删除,只是不再召回). 憧憬不被拒绝。',
227
+ parameters: {
228
+ entry: {
229
+ type: 'json',
230
+ required: true,
231
+ description: '{ name, reason?, conditions: { days, budget_cny, best_months }, muted?: boolean }'
232
+ }
233
+ },
234
+ output: {
235
+ schema: {
236
+ type: 'json'
237
+ },
238
+ render: (_args, value)=>{
239
+ const v = value;
240
+ return [
241
+ {
242
+ type: 'text',
243
+ text: `已加入「下一次出发」清单(共 ${v.total ?? '?'} 项):${v.path ?? ''}`
244
+ }
245
+ ];
246
+ }
247
+ },
248
+ async execute (args, _exec) {
249
+ const entry = args.entry ?? {};
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?: "行程起始(确认成行时挂时间线)" }'
280
+ }
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
+ } : {}
333
+ };
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) {
344
+ return {
345
+ ok: true,
346
+ suggestion: null,
347
+ summary: `无可成行的憧憬匹配当前窗口(${candidates.length} 条在册,0..1 纪律:不硬推)`
348
+ };
349
+ }
350
+ const { events: next } = ledger.appendUtilityEvent({
351
+ wish_id: match.wishId,
352
+ kind: 'recalled',
353
+ ts: now,
354
+ ctx: 'gotry_wish_pool_list.recall'
355
+ });
356
+ const utility = projectUtility(next)[match.wishId];
357
+ return {
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'}`
372
+ };
373
+ },
374
+ presentCall: (args)=>({
375
+ card: 'generic',
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
501
+ })
502
+ }));
503
+ registerGuarded(defineTool({
504
+ name: 'gotry_hotel_search',
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.',
506
+ parameters: {
507
+ query: {
508
+ type: 'json',
509
+ required: true,
510
+ description: '{ destination: "<目的地城市>", checkIn?: "YYYY-MM-DD 或自然表达(下周五/8.20)", checkOut?: 同上, occupancy?: { adults: 2 } }'
511
+ }
512
+ },
513
+ output: {
514
+ schema: {
515
+ type: 'json'
516
+ },
517
+ render: (_args, value)=>[
518
+ {
519
+ type: 'text',
520
+ text: String(value.summary ?? JSON.stringify(value).slice(0, 400))
521
+ }
522
+ ]
523
+ },
524
+ async execute (args, _exec) {
525
+ const q = unwrapQuery(args, 'destination');
526
+ if (!q.destination) throw new Error('gotry_hotel_search requires destination');
527
+ const started = Date.now();
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
+ };
541
+ const resp = await hbcliSearchHotels({
542
+ destination: q.destination,
543
+ checkIn: resolveDate(q.checkIn),
544
+ checkOut: resolveDate(q.checkOut),
545
+ adults: q.adults
546
+ }, {
547
+ hbcliBin: config.hbcliBin,
548
+ timeoutMs: config.timeoutMs,
549
+ fallbackPath
550
+ });
551
+ const dir = await ensureStateDir(config.stateRoot);
552
+ const isLive = resp.via === 'hbcli-realtime';
553
+ const evidence = isLive ? resp.evidence : '[静态包:估算]';
554
+ await recordLatency(join(dir, 'bridge-latency.jsonl'), Date.now() - started, `hotel_search:${resp.via}`).catch(()=>{});
555
+ const payload = {
556
+ ok: true,
557
+ hotels: resp.hotels ?? null,
558
+ evidence,
559
+ destination: q.destination,
560
+ via: resp.via,
561
+ latency_ms: Date.now() - started,
562
+ summary: resp.summary,
563
+ error: resp.error,
564
+ ...dateNotes.length ? {
565
+ date_notes: dateNotes
566
+ } : {}
567
+ };
568
+ return JSON.parse(JSON.stringify(payload));
569
+ },
570
+ presentCall: (args)=>({
571
+ card: 'generic',
572
+ title: `酒店搜索:${String(args.query?.destination ?? '')}`,
573
+ kind: 'search',
574
+ rawInput: args.query
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
+ }
590
+ }));
591
+ registerGuarded(defineTool({
592
+ name: 'gotry_skeleton_check',
593
+ 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.',
594
+ parameters: {
595
+ from: {
596
+ type: 'string',
597
+ required: true,
598
+ description: 'IATA, e.g. HKG'
599
+ },
600
+ to: {
601
+ type: 'string',
602
+ required: true,
603
+ description: 'IATA, e.g. HKT'
604
+ }
605
+ },
606
+ output: {
607
+ schema: {
608
+ type: 'json'
609
+ },
610
+ render: (_args, value)=>[
611
+ {
612
+ type: 'text',
613
+ text: String(value.evidence ?? JSON.stringify(value))
614
+ }
615
+ ]
616
+ },
617
+ async execute (args, _exec) {
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
+ }));
624
+ },
625
+ presentCall: (args)=>({
626
+ card: 'generic',
627
+ title: `骨架校验:${args.from}-${args.to}`,
628
+ kind: 'execute',
629
+ rawInput: args
630
+ })
631
+ }));
632
+ registerGuarded(defineTool({
633
+ name: 'gotry_weather_check',
634
+ 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.',
635
+ parameters: {
636
+ query: {
637
+ type: 'json',
638
+ required: true,
639
+ description: '{ place: "<城市名>", month?: 8, mode?: "forecast"|"climate", days?: 7 }'
640
+ }
641
+ },
642
+ output: {
643
+ schema: {
644
+ type: 'json'
645
+ },
646
+ render: (_args, value)=>[
647
+ {
648
+ type: 'text',
649
+ text: String(value.summary ?? JSON.stringify(value).slice(0, 600))
650
+ }
651
+ ]
652
+ },
653
+ async execute (args, _exec) {
654
+ const q = unwrapQuery(args, 'place');
655
+ const started = Date.now();
656
+ let lat = q.lat, lng = q.lng;
657
+ let placeLabel = q.place ?? `${q.lat},${q.lng}`;
658
+ if (lat === undefined || lng === undefined) {
659
+ if (!q.place) throw new Error('gotry_weather_check requires place name or lat/lng');
660
+ const geo = await geocodePlace(q.place);
661
+ if (!geo.ok || geo.results.length === 0) {
662
+ return JSON.parse(JSON.stringify({
663
+ ok: false,
664
+ summary: `地点「${q.place}」地理编码失败:${geo.error ?? '无结果'}`,
665
+ evidence: geo.evidence
666
+ }));
667
+ }
668
+ const hit = geo.results[0];
669
+ lat = hit.latitude;
670
+ lng = hit.longitude;
671
+ placeLabel = `${hit.name}(${hit.admin1 ?? hit.country ?? ''})`;
672
+ }
673
+ const isClimate = q.mode === 'climate' || q.month !== undefined && q.mode !== 'forecast';
674
+ const r = isClimate ? await getClimate({
675
+ latitude: lat,
676
+ longitude: lng
677
+ }, q.month ?? new Date().getMonth() + 1) : await getForecast({
678
+ latitude: lat,
679
+ longitude: lng
680
+ }, {
681
+ days: q.days
682
+ });
683
+ const dir = await ensureStateDir(config.stateRoot);
684
+ await recordLatency(join(dir, 'bridge-latency.jsonl'), Date.now() - started, `weather:${r.via}`).catch(()=>{});
685
+ 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}%` : ''}`);
686
+ const summary = r.ok ? `${placeLabel}:${isClimate ? '历史气候' : `${q.days ?? 7} 天预报`}\n${dailyLines.join('\n')}\n${r.evidence}` : `${placeLabel}:天气查询失败(${r.error})${r.evidence}`;
687
+ return JSON.parse(JSON.stringify({
688
+ ok: r.ok,
689
+ place: placeLabel,
690
+ mode: isClimate ? 'climate' : 'forecast',
691
+ daily: r.daily,
692
+ evidence: r.evidence,
693
+ summary,
694
+ latency_ms: Date.now() - started
695
+ }));
696
+ },
697
+ presentCall: (args)=>({
698
+ card: 'generic',
699
+ title: `天气:${String(args.query?.place ?? '')}`,
700
+ kind: 'fetch',
701
+ rawInput: args.query
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
+ }
718
+ }));
719
+ registerGuarded(defineTool({
720
+ name: 'gotry_flight_verify',
721
+ 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).',
722
+ parameters: {
723
+ query: {
724
+ type: 'json',
725
+ required: true,
726
+ description: '{ callsign: "EK329", airport?: "OMDB", timeoutMs?: 10000 }'
727
+ }
728
+ },
729
+ output: {
730
+ schema: {
731
+ type: 'json'
732
+ },
733
+ render: (_args, value)=>[
734
+ {
735
+ type: 'text',
736
+ text: String(value.summary ?? JSON.stringify(value).slice(0, 500))
737
+ }
738
+ ]
739
+ },
740
+ async execute (args, _exec) {
741
+ const q = unwrapQuery(args, 'callsign');
742
+ const started = Date.now();
743
+ if (!q.callsign) {
744
+ return JSON.parse(JSON.stringify({
745
+ verdict: 'unavailable',
746
+ evidence: '[校验不可用:无 callsign]',
747
+ summary: 'callsign 必填'
748
+ }));
749
+ }
750
+ const r = await verifyFlight({
751
+ callsign: q.callsign,
752
+ airport: q.airport,
753
+ timeoutMs: q.timeoutMs
754
+ });
755
+ const dir = await ensureStateDir(config.stateRoot);
756
+ await recordLatency(join(dir, 'bridge-latency.jsonl'), Date.now() - started, `flight_verify:${r.via}`).catch(()=>{});
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}`;
758
+ return JSON.parse(JSON.stringify({
759
+ ok: true,
760
+ verdict: r.verdict,
761
+ callsign: r.callsign,
762
+ airport: r.airport,
763
+ sample_size: r.sampleSize,
764
+ hits: r.hits,
765
+ evidence: r.evidence,
766
+ summary,
767
+ latency_ms: Date.now() - started
768
+ }));
769
+ },
770
+ presentCall: (args)=>({
771
+ card: 'generic',
772
+ title: `飞行校验:${String(args.query?.callsign ?? '')}`,
773
+ kind: 'fetch',
774
+ rawInput: args.query
775
+ })
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
+ }));
904
+ registerGuarded(defineTool({
905
+ name: 'gotry_anything_search',
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).',
907
+ parameters: {
908
+ query: {
909
+ type: 'json',
910
+ required: true,
911
+ description: '{ keyword: "<搜索关键词>", contentType?: "city"|"hotel", parentDestinationId?: "?", timeoutMs?: 12000 }'
912
+ }
913
+ },
914
+ output: {
915
+ schema: {
916
+ type: 'json'
917
+ },
918
+ render: (_args, value)=>[
919
+ {
920
+ type: 'text',
921
+ text: String(value.summary ?? JSON.stringify(value).slice(0, 800))
922
+ }
923
+ ]
924
+ },
925
+ async execute (args, _exec) {
926
+ const q = unwrapQuery(args, 'keyword');
927
+ const started = Date.now();
928
+ if (!q.keyword) {
929
+ return JSON.parse(JSON.stringify({
930
+ ok: false,
931
+ verdict: 'error',
932
+ summary: 'keyword 必填',
933
+ evidence: '[hbcli-anything@error] empty'
934
+ }));
935
+ }
936
+ const r = await anythingSearch(q);
937
+ const dir = await ensureStateDir(config.stateRoot);
938
+ await recordLatency(join(dir, 'bridge-latency.jsonl'), Date.now() - started, `anything:${r.via}`).catch(()=>{});
939
+ const top5 = (r.hits ?? []).slice(0, 5);
940
+ 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}`;
941
+ return JSON.parse(JSON.stringify({
942
+ ok: r.ok,
943
+ verdict: r.verdict,
944
+ keyword: q.keyword,
945
+ content_type: q.contentType ?? null,
946
+ total_candidates: r.totalCandidates,
947
+ hits: r.hits,
948
+ evidence: r.evidence,
949
+ summary,
950
+ latency_ms: Date.now() - started
951
+ }));
952
+ },
953
+ presentCall: (args)=>({
954
+ card: 'generic',
955
+ title: `Anything search:${String(args.query?.keyword ?? '')}`,
956
+ kind: 'search',
957
+ rawInput: args.query
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
+ }
973
+ }));
974
+ registerGuarded(defineTool({
975
+ name: 'gotry_web_search',
976
+ 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 证据链 + 降级不阻塞 + 三值)。',
977
+ parameters: {
978
+ query: {
979
+ type: 'json',
980
+ required: true,
981
+ description: '{ url: "https://example.com", timeoutMs?: 20000 }'
982
+ }
983
+ },
984
+ output: {
985
+ schema: {
986
+ type: 'json'
987
+ },
988
+ render: (_args, value)=>[
989
+ {
990
+ type: 'text',
991
+ text: String(value.content?.slice(0, 800) ?? JSON.stringify(value).slice(0, 800))
992
+ }
993
+ ]
994
+ },
995
+ async execute (args, _exec) {
996
+ const q = unwrapQuery(args, 'url');
997
+ const started = Date.now();
998
+ if (!q.url) {
999
+ return JSON.parse(JSON.stringify({
1000
+ ok: false,
1001
+ summary: 'url 必填',
1002
+ evidence: '[agent-reach:error] empty url'
1003
+ }));
1004
+ }
1005
+ const r = await readUrl({
1006
+ url: q.url,
1007
+ timeoutMs: q.timeoutMs
1008
+ });
1009
+ const dir = await ensureStateDir(config.stateRoot);
1010
+ await recordLatency(join(dir, 'bridge-latency.jsonl'), Date.now() - started, `agent-reach:${r.via}`).catch(()=>{});
1011
+ 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}`;
1012
+ return JSON.parse(JSON.stringify({
1013
+ ok: r.ok,
1014
+ url: q.url,
1015
+ via: r.via,
1016
+ title: r.title,
1017
+ content: r.content,
1018
+ evidence: r.evidence,
1019
+ summary,
1020
+ latency_ms: Date.now() - started
1021
+ }));
1022
+ },
1023
+ presentCall: (args)=>({
1024
+ card: 'generic',
1025
+ title: `读网页:${String(args.query?.url ?? '')}`,
1026
+ kind: 'fetch',
1027
+ rawInput: args.query
1028
+ })
1029
+ }));
1030
+ registerGuarded(defineTool({
1031
+ name: 'gotry_video_subtitle',
1032
+ 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].',
1033
+ parameters: {
1034
+ query: {
1035
+ type: 'json',
1036
+ required: true,
1037
+ description: '{ url: "https://www.youtube.com/watch?v=...", lang?: "zh-Hans,zh,en" }'
1038
+ }
1039
+ },
1040
+ output: {
1041
+ schema: {
1042
+ type: 'json'
1043
+ },
1044
+ render: (_args, value)=>[
1045
+ {
1046
+ type: 'text',
1047
+ text: String(value.summary ?? JSON.stringify(value).slice(0, 600))
1048
+ }
1049
+ ]
1050
+ },
1051
+ async execute (args, _exec) {
1052
+ const q = unwrapQuery(args, 'url');
1053
+ if (!q.url) {
1054
+ return JSON.parse(JSON.stringify({
1055
+ ok: false,
1056
+ summary: 'url 必填'
1057
+ }));
1058
+ }
1059
+ const r = await videoSubtitle({
1060
+ url: q.url,
1061
+ lang: q.lang
1062
+ });
1063
+ 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}`;
1064
+ return JSON.parse(JSON.stringify({
1065
+ ok: r.ok,
1066
+ verdict: r.verdict,
1067
+ url: q.url,
1068
+ subtitles: r.subtitles?.slice(0, 4000),
1069
+ evidence: r.evidence,
1070
+ summary,
1071
+ latency_ms: r.latencyMs
1072
+ }));
1073
+ },
1074
+ presentCall: (args)=>({
1075
+ card: 'generic',
1076
+ title: `视频字幕:${String(args.query?.url ?? '')}`,
1077
+ kind: 'fetch',
1078
+ rawInput: args.query
1079
+ })
1080
+ }));
1081
+ registerGuarded(defineTool({
1082
+ name: 'gotry_github_search',
1083
+ 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].',
1084
+ parameters: {
1085
+ query: {
1086
+ type: 'json',
1087
+ required: true,
1088
+ description: '{ query: "agent-reach", limit?: 5 }'
1089
+ }
1090
+ },
1091
+ output: {
1092
+ schema: {
1093
+ type: 'json'
1094
+ },
1095
+ render: (_args, value)=>[
1096
+ {
1097
+ type: 'text',
1098
+ text: String(value.summary ?? JSON.stringify(value).slice(0, 600))
1099
+ }
1100
+ ]
1101
+ },
1102
+ async execute (args, _exec) {
1103
+ const q = unwrapQuery(args, 'query');
1104
+ if (!q.query) {
1105
+ return JSON.parse(JSON.stringify({
1106
+ ok: false,
1107
+ summary: 'query 必填'
1108
+ }));
1109
+ }
1110
+ const r = await githubSearch({
1111
+ query: q.query,
1112
+ limit: q.limit
1113
+ });
1114
+ 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}`;
1115
+ return JSON.parse(JSON.stringify({
1116
+ ok: r.ok,
1117
+ verdict: r.verdict,
1118
+ query: q.query,
1119
+ repos: r.repos,
1120
+ evidence: r.evidence,
1121
+ summary,
1122
+ latency_ms: r.latencyMs
1123
+ }));
1124
+ },
1125
+ presentCall: (args)=>({
1126
+ card: 'generic',
1127
+ title: `GitHub 搜索:${String(args.query?.query ?? '')}`,
1128
+ kind: 'search',
1129
+ rawInput: args.query
1130
+ })
1131
+ }));
1132
+ registerGuarded(defineTool({
1133
+ name: 'gotry_agent_reach',
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].',
1135
+ parameters: {
1136
+ query: {
1137
+ type: 'json',
1138
+ required: true,
1139
+ description: '{ action: "status" } 或 { action: "reach", channel: "<上游渠道名,如 web/v2ex/xueqiu>", method: "<上游方法名,如 read/get_hot_topics/get_stock_quote>", args?: "<空格分隔参数>" }'
1140
+ }
1141
+ },
1142
+ output: {
1143
+ schema: {
1144
+ type: 'json'
1145
+ },
1146
+ render: (_args, value)=>[
1147
+ {
1148
+ type: 'text',
1149
+ text: String(value.summary ?? JSON.stringify(value).slice(0, 800))
1150
+ }
1151
+ ]
1152
+ },
1153
+ async execute (args, _exec) {
1154
+ const q = unwrapQuery(args, 'channel');
1155
+ const started = Date.now();
1156
+ const dir = await ensureStateDir(config.stateRoot);
1157
+ if (q.action === 'status' || !q.action && !q.channel) {
1158
+ const st = await reachStatus(q.timeoutMs);
1159
+ await recordLatency(join(dir, 'bridge-latency.jsonl'), Date.now() - started, 'agent-reach:doctor').catch(()=>{});
1160
+ 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}`;
1161
+ return JSON.parse(JSON.stringify({
1162
+ ok: st.ok,
1163
+ via: st.via,
1164
+ output: st.output,
1165
+ evidence: st.evidence,
1166
+ summary,
1167
+ latency_ms: Date.now() - started
1168
+ }));
1169
+ }
1170
+ if (!q.channel || !q.method) {
1171
+ return JSON.parse(JSON.stringify({
1172
+ ok: false,
1173
+ summary: 'channel 与 method 必填(或 action=status);清单可先随便调一次,inventory 会带回上游渠道/方法表'
1174
+ }));
1175
+ }
1176
+ const r = await reach({
1177
+ channel: q.channel,
1178
+ method: q.method,
1179
+ args: q.args,
1180
+ timeoutMs: q.timeoutMs
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);
1199
+ await recordLatency(join(dir, 'bridge-latency.jsonl'), Date.now() - started, `agent-reach:${q.channel}.${q.method}:${r.verdict}`).catch(()=>{});
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}`;
1201
+ return JSON.parse(JSON.stringify({
1202
+ ok: r.ok,
1203
+ channel: r.channel,
1204
+ method: q.method,
1205
+ verdict: r.verdict,
1206
+ data: typeof r.data === 'string' ? r.data.slice(0, 4000) : r.data,
1207
+ inventory: r.inventory,
1208
+ setup: r.setup,
1209
+ evidence: r.evidence,
1210
+ summary,
1211
+ latency_ms: Date.now() - started
1212
+ }));
1213
+ },
1214
+ presentCall: (args)=>({
1215
+ card: 'generic',
1216
+ title: `Agent Reach:${String(args.query?.channel ?? '')}.${String(args.query?.method ?? 'status')}`,
1217
+ kind: 'fetch',
1218
+ rawInput: args.query
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
+ }
1235
+ }));
1236
+ }
1237
+
1238
+
1239
+ //# sourceURL=/Users/bytedance/work/gotry/ts/src/index.ts