@danceiny/gotry 0.0.1-rc.5
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.
- package/LICENSE +21 -0
- package/README.md +312 -0
- package/bin/gotry-inner.js +124 -0
- package/bin/gotry.js +28 -0
- package/cordis.gotry-patch.yml +28 -0
- package/package.json +53 -0
- package/ts/capabilities/hbcli.ts +152 -0
- package/ts/capabilities/incident-log.ts +145 -0
- package/ts/cordis.gotry-patch.yml +36 -0
- package/ts/package.json +23 -0
- package/ts/scripts/skeleton-check.ts +45 -0
- package/ts/scripts/skeleton-integration-test.ts +23 -0
- package/ts/src/bridge.ts +44 -0
- package/ts/src/contracts.ts +165 -0
- package/ts/src/dsh-llm.ts +135 -0
- package/ts/src/index.ts +620 -0
- package/ts/src/loop.ts +336 -0
- package/ts/src/mock-llm.ts +88 -0
- package/ts/src/model.ts +220 -0
- package/ts/src/unified.ts +577 -0
package/ts/src/index.ts
ADDED
|
@@ -0,0 +1,620 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* GoTry dsh 插件(gotry-tools):把 GoTry 的领域能力注册为 dsh 工具。
|
|
3
|
+
*
|
|
4
|
+
* 对应总纲 3.2 插件清单中的三个最小集:
|
|
5
|
+
* - gotry_feasibility_check 可行性引擎(门到门全成本,bridge → Python Z3)
|
|
6
|
+
* - gotry_motivation_save 动机画像落盘(为什么出发;B2B 接缝的契约对象)
|
|
7
|
+
* - gotry_wish_pool_add 「下一次出发」清单(憧憬不被拒绝)
|
|
8
|
+
*
|
|
9
|
+
* 插件形态遵循 dsh 约定(name/inject/Config/apply + ctx.tools.register(defineTool(...))),
|
|
10
|
+
* 对齐已发布 @deepseek-ai/dsh-tools@0.0.1-rc.1 的契约:
|
|
11
|
+
* render 位于 output 对象内,参数属性是 ValueSchemaSpec(支持 type:'json')。
|
|
12
|
+
*
|
|
13
|
+
* @module @gotry/plugin
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
import { join } from 'node:path'
|
|
17
|
+
import type { Context } from '@deepseek-ai/cordis'
|
|
18
|
+
import z from '@deepseek-ai/schemastery'
|
|
19
|
+
import { defineTool } from '@deepseek-ai/dsh-tools'
|
|
20
|
+
import { ensureStateDir, readJson, recordLatency, writeJson } from './bridge.ts'
|
|
21
|
+
import { segmentsFromCandidate, solveChoiceSegment } from './unified.ts'
|
|
22
|
+
import { checkConnectivity } from '../scripts/skeleton-check.ts'
|
|
23
|
+
import { parseCandidate, parseRequest } from './model.ts'
|
|
24
|
+
import { searchHotels as hbcliSearchHotels } from '../capabilities/hbcli.ts'
|
|
25
|
+
import { installProcessGuards, guardToolExecute } from '../capabilities/incident-log.ts'
|
|
26
|
+
import { geocodePlace, getForecast, getClimate, wmoLabel } from '../capabilities/weather.ts'
|
|
27
|
+
import { verifyFlight } from '../capabilities/opensky.ts'
|
|
28
|
+
import { anythingSearch } from '../capabilities/anything.ts'
|
|
29
|
+
import { readUrl, reach, reachStatus } from '../capabilities/agent-reach.ts'
|
|
30
|
+
import { videoSubtitle, githubSearch } from '../capabilities/agent-reach-deep.ts'
|
|
31
|
+
|
|
32
|
+
export const name = 'gotry-tools'
|
|
33
|
+
export const inject = ['tools', 'systemPrompt']
|
|
34
|
+
|
|
35
|
+
export interface Config {
|
|
36
|
+
/** 状态根目录(动机画像、wish pool、延迟日志) */
|
|
37
|
+
stateRoot: string
|
|
38
|
+
/** 引擎调用超时(ms) */
|
|
39
|
+
timeoutMs: number
|
|
40
|
+
/** hbcli 二进制路径(hotelbyte-cli;空=禁用实时酒店,回退数据包) */
|
|
41
|
+
hbcliBin: string
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export const Config: z<Config> = z.object({
|
|
45
|
+
stateRoot: z.string().default('.'),
|
|
46
|
+
timeoutMs: z.number().default(30_000),
|
|
47
|
+
hbcliBin: z.string().default('hbcli'),
|
|
48
|
+
})
|
|
49
|
+
|
|
50
|
+
interface FeasibilityResult {
|
|
51
|
+
answer_md?: string
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
interface MotivationProfileInput {
|
|
55
|
+
weights?: Record<string, number>
|
|
56
|
+
evidence?: string[]
|
|
57
|
+
hard?: Record<string, unknown>
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
interface WishPoolEntryInput {
|
|
61
|
+
name?: string
|
|
62
|
+
reason?: string
|
|
63
|
+
conditions?: Record<string, unknown>
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
type Json = string | number | boolean | null | Json[] | { [k: string]: Json }
|
|
67
|
+
type JsonObject = { [k: string]: Json }
|
|
68
|
+
|
|
69
|
+
export function apply(ctx: Context, config: Config): void {
|
|
70
|
+
// 时间感知:注册动态变量,persona 里用 {{current_date}} 引用。
|
|
71
|
+
// 每次 assemble 时取系统时钟——LLM 始终知道「今天是几号」。
|
|
72
|
+
const sp = (ctx as unknown as Record<string, unknown>)['systemPrompt'] as {
|
|
73
|
+
variable?: (name: string, provider: () => string) => void
|
|
74
|
+
} | undefined
|
|
75
|
+
sp?.variable?.('current_date', () => {
|
|
76
|
+
const d = new Date()
|
|
77
|
+
const ymd = `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`
|
|
78
|
+
const weekdays = ['日', '一', '二', '三', '四', '五', '六']
|
|
79
|
+
return `${ymd} 周${weekdays[d.getDay()]}`
|
|
80
|
+
})
|
|
81
|
+
|
|
82
|
+
// D-NEW 进程护栏(Z3 WASM crash 教训):dsh 0.1.1-rc.1 缺 uncaughtException
|
|
83
|
+
// handler,插件异常穿透即杀进程。我们在 gotry 侧挂护栏:同步 fsync 写事故证据
|
|
84
|
+
// (gotry-state/incidents.jsonl),handler 自身不再抛,不阻塞后续控制流。
|
|
85
|
+
// 不调 process.exit——让 dsh/上级容器决定生死,我们只留现场。
|
|
86
|
+
installProcessGuards(config.stateRoot ?? '.', { uncaughtException: 'gotry-tools', unhandledRejection: 'gotry-tools' })
|
|
87
|
+
|
|
88
|
+
// D-NEW 收尾:全部工具 execute 统一异常隔离——单个工具抛错/拒绝不再沿 cordis
|
|
89
|
+
// 传到 dsh 主循环,降级为结构化错误返回给 LLM + incident 落盘(incident-log.ts)。
|
|
90
|
+
const registerGuarded = (tool: ReturnType<typeof defineTool>): void => {
|
|
91
|
+
const t = { ...(tool as unknown as Record<string, unknown>) }
|
|
92
|
+
if (typeof t.execute === 'function') {
|
|
93
|
+
t.execute = guardToolExecute(String(t.name), config.stateRoot ?? '.', t.execute as (args: never, exec: unknown) => never)
|
|
94
|
+
}
|
|
95
|
+
ctx.tools.register(t as unknown as ReturnType<typeof defineTool>)
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
registerGuarded(defineTool({
|
|
99
|
+
name: 'gotry_feasibility_check',
|
|
100
|
+
description:
|
|
101
|
+
'Check travel candidates against the user\'s motivation and hard constraints using the '
|
|
102
|
+
+ 'door-to-door true-cost engine (wake time, arrival state, energy, usable hours, money). '
|
|
103
|
+
+ 'Input is the structured request (motivation weights + hard constraints + window + budget + home hubs) '
|
|
104
|
+
+ 'and the candidate list (services/transfers/stay costs/min days), same shape as data/golden_erhai.json. '
|
|
105
|
+
+ 'Returns per-candidate verdicts, unsat cores with minimal-modification suggestions, '
|
|
106
|
+
+ 'a wish-pool entry for infeasible aspirations, and a ready-to-show markdown answer.',
|
|
107
|
+
parameters: {
|
|
108
|
+
payload: {
|
|
109
|
+
type: 'json',
|
|
110
|
+
required: true,
|
|
111
|
+
description: 'The full engine payload: { request, candidates }.',
|
|
112
|
+
},
|
|
113
|
+
},
|
|
114
|
+
output: {
|
|
115
|
+
schema: { type: 'json' },
|
|
116
|
+
render: (_args, value) => [{
|
|
117
|
+
type: 'text',
|
|
118
|
+
text: String((value as FeasibilityResult).answer_md ?? JSON.stringify(value)),
|
|
119
|
+
}],
|
|
120
|
+
},
|
|
121
|
+
async execute(args: { payload: unknown }, _exec: unknown) {
|
|
122
|
+
// 纯 TS 路径:D-7 后 unified solveChoiceSegment 是唯一求解入口(无 Python 桥、无 z3 WASM
|
|
123
|
+
// 路径——候选形态枚举求解,~6ms/次)。unified 内部有 try-catch 护栏覆盖 wasm 异常。
|
|
124
|
+
const started = Date.now()
|
|
125
|
+
const payload = args.payload as Record<string, unknown>
|
|
126
|
+
const req = parseRequest(payload['request'] as Record<string, unknown>)
|
|
127
|
+
const cands = (payload['candidates'] as Record<string, unknown>[]).map(parseCandidate)
|
|
128
|
+
const spec = segmentsFromCandidate(req, cands)
|
|
129
|
+
const result = solveChoiceSegment(spec, req) as Record<string, unknown>
|
|
130
|
+
const dir = await ensureStateDir(config.stateRoot)
|
|
131
|
+
await recordLatency(join(dir, 'bridge-latency.jsonl'), Date.now() - started, 'feasibility_check:in-process-unified').catch(() => {})
|
|
132
|
+
return { ...result, latency_ms: Date.now() - started, via: 'in-process-unified' }
|
|
133
|
+
},
|
|
134
|
+
presentCall: args => ({ card: 'generic', title: 'GoTry 可行性检查(门到门全成本)', kind: 'other', rawInput: args.payload }),
|
|
135
|
+
}))
|
|
136
|
+
|
|
137
|
+
registerGuarded(defineTool({
|
|
138
|
+
name: 'gotry_motivation_save',
|
|
139
|
+
description:
|
|
140
|
+
'Persist the traveler\'s motivation profile (the "why depart" contract object). '
|
|
141
|
+
+ 'This is the B2B reuse seam: downstream plugins consume only MotivationProfile + constraints, '
|
|
142
|
+
+ 'never the principal/sponsor distinction. Requires an evidence field: every weight must trace '
|
|
143
|
+
+ 'back to the user\'s own words (P0 anti-fabrication rule).',
|
|
144
|
+
parameters: {
|
|
145
|
+
profile: {
|
|
146
|
+
type: 'json',
|
|
147
|
+
required: true,
|
|
148
|
+
description: '{ weights: {escape_rest: 0.7, ...}, evidence: [user quotes...], hard: {wake_not_before, min_arrival_energy_pct} }',
|
|
149
|
+
},
|
|
150
|
+
},
|
|
151
|
+
output: {
|
|
152
|
+
schema: {
|
|
153
|
+
type: 'object',
|
|
154
|
+
additionalProperties: false,
|
|
155
|
+
properties: {
|
|
156
|
+
saved: { type: 'boolean' },
|
|
157
|
+
path: { type: 'string' },
|
|
158
|
+
profile: { type: 'json' },
|
|
159
|
+
},
|
|
160
|
+
},
|
|
161
|
+
render: (_args, value) => [{ type: 'text', text: `动机画像已保存:${String((value as { path?: string }).path ?? '')}` }],
|
|
162
|
+
},
|
|
163
|
+
async execute(args: { profile: unknown }, _exec: unknown) {
|
|
164
|
+
const profile = (args.profile ?? {}) as MotivationProfileInput
|
|
165
|
+
if (!profile.evidence?.length) {
|
|
166
|
+
throw new Error('refusing to save a motivation profile without evidence (P0 anti-fabrication rule)')
|
|
167
|
+
}
|
|
168
|
+
const dir = await ensureStateDir(config.stateRoot)
|
|
169
|
+
const path = join(dir, 'motivation-profile.json')
|
|
170
|
+
// JSON 往返保证落盘值与输出值都是 JsonValue(输出 schema 的硬要求)
|
|
171
|
+
const saved = JSON.parse(JSON.stringify({ ...profile, updated_at: new Date().toISOString() })) as JsonObject
|
|
172
|
+
await writeJson(path, saved)
|
|
173
|
+
return { saved: true, path, profile: saved }
|
|
174
|
+
},
|
|
175
|
+
presentCall: args => ({ card: 'generic', title: '保存动机画像', kind: 'other', rawInput: args.profile }),
|
|
176
|
+
}))
|
|
177
|
+
|
|
178
|
+
registerGuarded(defineTool({
|
|
179
|
+
name: 'gotry_wish_pool_add',
|
|
180
|
+
description:
|
|
181
|
+
'Add an aspiration to the "next departure" wish pool — the graceful home for infeasible dreams. '
|
|
182
|
+
+ 'An entry carries its fulfilment conditions (days needed, budget, best months) so a future '
|
|
183
|
+
+ '"next departure" nudge can fire when the window matches. 憧憬不被拒绝。',
|
|
184
|
+
parameters: {
|
|
185
|
+
entry: {
|
|
186
|
+
type: 'json',
|
|
187
|
+
required: true,
|
|
188
|
+
description: '{ name, reason, conditions: { days, budget_cny, best_months } }',
|
|
189
|
+
},
|
|
190
|
+
},
|
|
191
|
+
output: {
|
|
192
|
+
schema: {
|
|
193
|
+
type: 'object',
|
|
194
|
+
additionalProperties: false,
|
|
195
|
+
properties: {
|
|
196
|
+
added: { type: 'boolean' },
|
|
197
|
+
total: { type: 'integer' },
|
|
198
|
+
path: { type: 'string' },
|
|
199
|
+
},
|
|
200
|
+
},
|
|
201
|
+
render: (_args, value) => {
|
|
202
|
+
const v = value as { total?: number; path?: string }
|
|
203
|
+
return [{ type: 'text', text: `已加入「下一次出发」清单(共 ${v.total ?? '?'} 项):${v.path ?? ''}` }]
|
|
204
|
+
},
|
|
205
|
+
},
|
|
206
|
+
async execute(args: { entry: unknown }, _exec: unknown) {
|
|
207
|
+
const entry = (args.entry ?? {}) as WishPoolEntryInput
|
|
208
|
+
if (!entry.name || !entry.conditions) {
|
|
209
|
+
throw new Error('wish pool entry requires name and conditions (fulfilment conditions are the whole point)')
|
|
210
|
+
}
|
|
211
|
+
const dir = await ensureStateDir(config.stateRoot)
|
|
212
|
+
const path = join(dir, 'wish-pool.json')
|
|
213
|
+
const pool = (await readJson(path, [])) as Array<Record<string, unknown>>
|
|
214
|
+
// 同名憧憬幂等更新(刷新理由与成行条件),不重复落盘
|
|
215
|
+
const existing = pool.findIndex(e => e['name'] === entry.name)
|
|
216
|
+
if (existing >= 0) {
|
|
217
|
+
pool[existing] = { ...pool[existing], reason: entry.reason ?? pool[existing]?.['reason'], conditions: entry.conditions }
|
|
218
|
+
await writeJson(path, pool)
|
|
219
|
+
return { added: false, total: pool.length, path }
|
|
220
|
+
}
|
|
221
|
+
pool.push({ reason: '', ...entry, added_at: new Date().toISOString() })
|
|
222
|
+
await writeJson(path, pool)
|
|
223
|
+
return { added: true, total: pool.length, path }
|
|
224
|
+
},
|
|
225
|
+
presentCall: args => ({ card: 'generic', title: '加入「下一次出发」清单', kind: 'other', rawInput: args.entry }),
|
|
226
|
+
}))
|
|
227
|
+
|
|
228
|
+
registerGuarded(defineTool({
|
|
229
|
+
name: 'gotry_hotel_search',
|
|
230
|
+
description:
|
|
231
|
+
'Search hotels via hotelbyte-cli (real-time when hbcli credentials exist, falls back to the static pack with explicit evidence tagging). '
|
|
232
|
+
+ 'Input: destination city name + optional dates/occupancy. Output: hotel list with evidence chain ([realtime-API:hbcli] + fetch timestamp, '
|
|
233
|
+
+ 'or [static-pack:estimate]) per the L4 invariant.',
|
|
234
|
+
parameters: {
|
|
235
|
+
query: {
|
|
236
|
+
type: 'json',
|
|
237
|
+
required: true,
|
|
238
|
+
description: '{ destination: "普吉", checkIn?: "2026-07-18", checkOut?: "2026-07-23", occupancy?: { adults: 2 } }',
|
|
239
|
+
},
|
|
240
|
+
},
|
|
241
|
+
output: {
|
|
242
|
+
schema: { type: 'json' },
|
|
243
|
+
render: (_args, value) => [{ type: 'text', text: String((value as { summary?: string }).summary ?? JSON.stringify(value).slice(0, 400)) }],
|
|
244
|
+
},
|
|
245
|
+
async execute(args: { query: unknown }, _exec: unknown) {
|
|
246
|
+
const q = (args.query ?? {}) as { destination?: string; checkIn?: string; checkOut?: string; adults?: number }
|
|
247
|
+
if (!q.destination) throw new Error('gotry_hotel_search requires destination')
|
|
248
|
+
const started = Date.now()
|
|
249
|
+
const fallbackPath = join(import.meta.dirname, '..', '..', 'data', 'hotels_2026.json')
|
|
250
|
+
const resp = await hbcliSearchHotels(
|
|
251
|
+
{ destination: q.destination, checkIn: q.checkIn, checkOut: q.checkOut, adults: q.adults },
|
|
252
|
+
{ hbcliBin: config.hbcliBin, timeoutMs: config.timeoutMs, fallbackPath },
|
|
253
|
+
)
|
|
254
|
+
const dir = await ensureStateDir(config.stateRoot)
|
|
255
|
+
const isLive = resp.via === 'hbcli-realtime'
|
|
256
|
+
const evidence = isLive ? resp.evidence : '[静态包:估算]'
|
|
257
|
+
await recordLatency(join(dir, 'bridge-latency.jsonl'), Date.now() - started, `hotel_search:${resp.via}`).catch(() => {})
|
|
258
|
+
const payload = {
|
|
259
|
+
hotels: resp.hotels ?? null,
|
|
260
|
+
evidence,
|
|
261
|
+
destination: q.destination,
|
|
262
|
+
via: resp.via,
|
|
263
|
+
latency_ms: Date.now() - started,
|
|
264
|
+
summary: resp.summary,
|
|
265
|
+
error: resp.error,
|
|
266
|
+
}
|
|
267
|
+
return JSON.parse(JSON.stringify(payload)) as Record<string, unknown>
|
|
268
|
+
},
|
|
269
|
+
presentCall: args => ({ card: 'generic', title: `酒店搜索:${String((args.query as { destination?: string })?.destination ?? '')}`, kind: 'other', rawInput: args.query }),
|
|
270
|
+
}))
|
|
271
|
+
|
|
272
|
+
registerGuarded(defineTool({
|
|
273
|
+
name: 'gotry_skeleton_check',
|
|
274
|
+
description:
|
|
275
|
+
'Check flight connectivity between two airports against the OpenFlights skeleton (free tier). '
|
|
276
|
+
+ 'Three-valued: found = strong positive (airlines returned); hub-to-hub absence = downgrade signal, NEVER disproof '
|
|
277
|
+
+ '(skeleton lags reality); outside hub set = no conclusion. Use BEFORE recommending a route.',
|
|
278
|
+
parameters: {
|
|
279
|
+
from: { type: 'string', required: true, description: 'IATA, e.g. HKG' },
|
|
280
|
+
to: { type: 'string', required: true, description: 'IATA, e.g. HKT' },
|
|
281
|
+
},
|
|
282
|
+
output: {
|
|
283
|
+
schema: {
|
|
284
|
+
type: 'object',
|
|
285
|
+
additionalProperties: false,
|
|
286
|
+
properties: {
|
|
287
|
+
connected: { type: 'boolean' },
|
|
288
|
+
airlines: { type: 'array', items: { type: 'string' } },
|
|
289
|
+
evidence: { type: 'string' },
|
|
290
|
+
},
|
|
291
|
+
},
|
|
292
|
+
render: (_args, value) => [{ type: 'text', text: String((value as { evidence?: string }).evidence ?? '') }],
|
|
293
|
+
},
|
|
294
|
+
async execute(args: { from: string; to: string }, _exec: unknown) {
|
|
295
|
+
const verdict = await checkConnectivity(args.from, args.to)
|
|
296
|
+
return JSON.parse(JSON.stringify(verdict)) as Record<string, never>
|
|
297
|
+
},
|
|
298
|
+
presentCall: args => ({ card: 'generic', title: `骨架校验:${args.from}-${args.to}`, kind: 'other', rawInput: args }),
|
|
299
|
+
}))
|
|
300
|
+
|
|
301
|
+
registerGuarded(defineTool({
|
|
302
|
+
name: 'gotry_weather_check',
|
|
303
|
+
description:
|
|
304
|
+
'Check weather for a destination: forecast (≤16 days) or historical climate (seasonality baseline). '
|
|
305
|
+
+ 'Free Open-Meteo API, no key required. Input: place name (Chinese ok) or lat/lng. '
|
|
306
|
+
+ 'Returns daily temp range, precipitation probability, weather code — with evidence chain tagging '
|
|
307
|
+
+ '[实时API:open-meteo@ts]. Use to ground seasonal advice in real data instead of LLM guessing.',
|
|
308
|
+
parameters: {
|
|
309
|
+
query: {
|
|
310
|
+
type: 'json',
|
|
311
|
+
required: true,
|
|
312
|
+
description: '{ place: "大理市", month?: 8, mode?: "forecast"|"climate", days?: 7 }',
|
|
313
|
+
},
|
|
314
|
+
},
|
|
315
|
+
output: {
|
|
316
|
+
schema: { type: 'json' },
|
|
317
|
+
render: (_args, value) => [{ type: 'text', text: String((value as { summary?: string }).summary ?? JSON.stringify(value).slice(0, 600)) }],
|
|
318
|
+
},
|
|
319
|
+
async execute(args: { query: unknown }, _exec: unknown) {
|
|
320
|
+
const q = (args.query ?? {}) as { place?: string; lat?: number; lng?: number; month?: number; mode?: string; days?: number }
|
|
321
|
+
const started = Date.now()
|
|
322
|
+
let lat: number | undefined = q.lat, lng: number | undefined = q.lng
|
|
323
|
+
let placeLabel = q.place ?? `${q.lat},${q.lng}`
|
|
324
|
+
if (lat === undefined || lng === undefined) {
|
|
325
|
+
if (!q.place) throw new Error('gotry_weather_check requires place name or lat/lng')
|
|
326
|
+
const geo = await geocodePlace(q.place)
|
|
327
|
+
if (!geo.ok || geo.results.length === 0) {
|
|
328
|
+
return JSON.parse(JSON.stringify({ ok: false, summary: `地点「${q.place}」地理编码失败:${geo.error ?? '无结果'}`, evidence: geo.evidence })) as Record<string, never>
|
|
329
|
+
}
|
|
330
|
+
const hit = geo.results[0]
|
|
331
|
+
lat = hit.latitude; lng = hit.longitude
|
|
332
|
+
placeLabel = `${hit.name}(${hit.admin1 ?? hit.country ?? ''})`
|
|
333
|
+
}
|
|
334
|
+
const isClimate = q.mode === 'climate' || (q.month !== undefined && q.mode !== 'forecast')
|
|
335
|
+
const r = isClimate
|
|
336
|
+
? await getClimate({ latitude: lat, longitude: lng }, q.month ?? new Date().getMonth() + 1)
|
|
337
|
+
: await getForecast({ latitude: lat, longitude: lng }, { days: q.days })
|
|
338
|
+
const dir = await ensureStateDir(config.stateRoot)
|
|
339
|
+
await recordLatency(join(dir, 'bridge-latency.jsonl'), Date.now() - started, `weather:${r.via}`).catch(() => {})
|
|
340
|
+
const dailyLines = (r.daily ?? []).slice(0, 7).map(d =>
|
|
341
|
+
`${d.date} ${d.tempMinC.toFixed(0)}–${d.tempMaxC.toFixed(0)}°C ${wmoLabel(d.weatherCode)}${d.precipProbMaxPct !== null ? ` 降水概率${d.precipProbMaxPct}%` : ''}`)
|
|
342
|
+
const summary = r.ok
|
|
343
|
+
? `${placeLabel}:${isClimate ? '历史气候' : `${q.days ?? 7} 天预报`}\n${dailyLines.join('\n')}\n${r.evidence}`
|
|
344
|
+
: `${placeLabel}:天气查询失败(${r.error})${r.evidence}`
|
|
345
|
+
return JSON.parse(JSON.stringify({
|
|
346
|
+
ok: r.ok, place: placeLabel, mode: isClimate ? 'climate' : 'forecast',
|
|
347
|
+
daily: r.daily, evidence: r.evidence, summary,
|
|
348
|
+
latency_ms: Date.now() - started,
|
|
349
|
+
})) as Record<string, never>
|
|
350
|
+
},
|
|
351
|
+
presentCall: args => ({ card: 'generic', title: `天气:${String((args.query as { place?: string })?.place ?? '')}`, kind: 'other', rawInput: args.query }),
|
|
352
|
+
}))
|
|
353
|
+
|
|
354
|
+
registerGuarded(defineTool({
|
|
355
|
+
name: 'gotry_flight_verify',
|
|
356
|
+
description:
|
|
357
|
+
'Verify whether a flight callsign is currently observable on the OpenSky ADS-B network. '
|
|
358
|
+
+ 'Free anonymous API (~400 credits/day, 4 req/s burst). Three-valued semantics: '
|
|
359
|
+
+ 'observed = strong positive (the aircraft is currently being broadcast); '
|
|
360
|
+
+ 'not_observed = no conclusion (ADS-B coverage is limited by geography/altitude — '
|
|
361
|
+
+ 'a missing signal does NOT disprove the flight); '
|
|
362
|
+
+ 'unavailable = API failure, gracefully degraded. '
|
|
363
|
+
+ 'Use to ground "is this flight actually flying right now?" in real data, complementing '
|
|
364
|
+
+ 'the OpenFlights skeleton (historical connectivity) and the static flight pack (planned schedule).',
|
|
365
|
+
parameters: {
|
|
366
|
+
query: {
|
|
367
|
+
type: 'json',
|
|
368
|
+
required: true,
|
|
369
|
+
description: '{ callsign: "EK329", airport?: "OMDB", timeoutMs?: 10000 }',
|
|
370
|
+
},
|
|
371
|
+
},
|
|
372
|
+
output: {
|
|
373
|
+
schema: { type: 'json' },
|
|
374
|
+
render: (_args, value) => [{ type: 'text', text: String((value as { summary?: string }).summary ?? JSON.stringify(value).slice(0, 500)) }],
|
|
375
|
+
},
|
|
376
|
+
async execute(args: { query: unknown }, _exec: unknown) {
|
|
377
|
+
const q = (args.query ?? {}) as { callsign: string; airport?: string; timeoutMs?: number }
|
|
378
|
+
const started = Date.now()
|
|
379
|
+
if (!q.callsign) {
|
|
380
|
+
return JSON.parse(JSON.stringify({ verdict: 'unavailable', evidence: '[校验不可用:无 callsign]', summary: 'callsign 必填' })) as Record<string, never>
|
|
381
|
+
}
|
|
382
|
+
const r = await verifyFlight({ callsign: q.callsign, airport: q.airport, timeoutMs: q.timeoutMs })
|
|
383
|
+
const dir = await ensureStateDir(config.stateRoot)
|
|
384
|
+
await recordLatency(join(dir, 'bridge-latency.jsonl'), Date.now() - started, `flight_verify:${r.via}`).catch(() => {})
|
|
385
|
+
const summary = r.verdict === 'observed'
|
|
386
|
+
? `${r.callsign} 当前 ADS-B 观测命中 (${r.hits?.length ?? 0} 架)${r.airport ? ` 在 ${r.airport}` : ''}\n${r.evidence}`
|
|
387
|
+
: r.verdict === 'not_observed'
|
|
388
|
+
? `${r.callsign} 当前观测列表未见(ADS-B 覆盖有限,不否定该航班存在)\n${r.evidence}`
|
|
389
|
+
: `${r.callsign} OpenSky 不可用:${r.error}\n${r.evidence}`
|
|
390
|
+
return JSON.parse(JSON.stringify({
|
|
391
|
+
verdict: r.verdict, callsign: r.callsign, airport: r.airport,
|
|
392
|
+
sample_size: r.sampleSize, hits: r.hits, evidence: r.evidence, summary,
|
|
393
|
+
latency_ms: Date.now() - started,
|
|
394
|
+
})) as Record<string, never>
|
|
395
|
+
},
|
|
396
|
+
presentCall: args => ({ card: 'generic', title: `飞行校验:${String((args.query as { callsign?: string })?.callsign ?? '')}`, kind: 'other', rawInput: args.query }),
|
|
397
|
+
}))
|
|
398
|
+
|
|
399
|
+
registerGuarded(defineTool({
|
|
400
|
+
name: 'gotry_anything_search',
|
|
401
|
+
description:
|
|
402
|
+
'Universal Anything search via hotel-byte CLI → hotel-be Anything endpoint. ' +
|
|
403
|
+
'Mixed destinations (cities / metropolitan areas / high-level regions) + hotels in one call. ' +
|
|
404
|
+
'Returns candidates with type, name, optional coordinates and hotel-id. ' +
|
|
405
|
+
'Three-valued semantics: hit = ≥1 candidate; miss = 0 candidates (try synonyms or contentType=city/hotel); ' +
|
|
406
|
+
'unavailable = hbcli failed (degraded, never blocks). ' +
|
|
407
|
+
'Use as the first stop when the user mentions a place/city/hotel name and you need to ground it in real catalog data ' +
|
|
408
|
+
'(OpenFlights skeleton tells you connectivity; Anything tells you what EXISTS at a city/region).',
|
|
409
|
+
parameters: {
|
|
410
|
+
query: {
|
|
411
|
+
type: 'json',
|
|
412
|
+
required: true,
|
|
413
|
+
description: '{ keyword: "大理", contentType?: "city"|"hotel", parentDestinationId?: "?", timeoutMs?: 12000 }',
|
|
414
|
+
},
|
|
415
|
+
},
|
|
416
|
+
output: {
|
|
417
|
+
schema: { type: 'json' },
|
|
418
|
+
render: (_args, value) => [{ type: 'text', text: String((value as { summary?: string }).summary ?? JSON.stringify(value).slice(0, 800)) }],
|
|
419
|
+
},
|
|
420
|
+
async execute(args: { query: unknown }, _exec: unknown) {
|
|
421
|
+
const q = (args.query ?? {}) as { keyword: string; contentType?: 'city' | 'hotel'; parentDestinationId?: string | number; timeoutMs?: number }
|
|
422
|
+
const started = Date.now()
|
|
423
|
+
if (!q.keyword) {
|
|
424
|
+
return JSON.parse(JSON.stringify({ ok: false, verdict: 'error', summary: 'keyword 必填', evidence: '[hbcli-anything@error] empty' })) as Record<string, never>
|
|
425
|
+
}
|
|
426
|
+
const r = await anythingSearch(q)
|
|
427
|
+
const dir = await ensureStateDir(config.stateRoot)
|
|
428
|
+
await recordLatency(join(dir, 'bridge-latency.jsonl'), Date.now() - started, `anything:${r.via}`).catch(() => {})
|
|
429
|
+
const top5 = (r.hits ?? []).slice(0, 5)
|
|
430
|
+
const summary = r.verdict === 'hit'
|
|
431
|
+
? `${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}`
|
|
432
|
+
: r.verdict === 'miss'
|
|
433
|
+
? `${q.keyword} → miss (酒店-be 一切正常但无候选)\n${r.evidence}`
|
|
434
|
+
: `${q.keyword} → unavailable (${r.error})\n${r.evidence}`
|
|
435
|
+
return JSON.parse(JSON.stringify({
|
|
436
|
+
ok: r.ok, verdict: r.verdict, keyword: q.keyword,
|
|
437
|
+
content_type: q.contentType ?? null,
|
|
438
|
+
total_candidates: r.totalCandidates, hits: r.hits, evidence: r.evidence, summary,
|
|
439
|
+
latency_ms: Date.now() - started,
|
|
440
|
+
})) as Record<string, never>
|
|
441
|
+
},
|
|
442
|
+
presentCall: args => ({ card: 'generic', title: `Anything search:${String((args.query as { keyword?: string })?.keyword ?? '')}`, kind: 'other', rawInput: args.query }),
|
|
443
|
+
}))
|
|
444
|
+
|
|
445
|
+
registerGuarded(defineTool({
|
|
446
|
+
name: 'gotry_web_search',
|
|
447
|
+
description:
|
|
448
|
+
'Read any public URL as markdown (Jina Reader, free, no key). ' +
|
|
449
|
+
'Use as the "last mile" web reader when hotel-be Anything or gotry tools lack the answer. ' +
|
|
450
|
+
'NOT a general-purpose search engine — only fetches a URL you already know. ' +
|
|
451
|
+
'Three-valued: ok / error(非法 URL/超时)/not-reachable(r.jina.ai 不可用).' +
|
|
452
|
+
'Contract with gotry capabilities/anything.ts: 同构(L4 证据链 + 降级不阻塞 + 三值)。',
|
|
453
|
+
parameters: {
|
|
454
|
+
query: {
|
|
455
|
+
type: 'json',
|
|
456
|
+
required: true,
|
|
457
|
+
description: '{ url: "https://example.com", timeoutMs?: 20000 }',
|
|
458
|
+
},
|
|
459
|
+
},
|
|
460
|
+
output: {
|
|
461
|
+
schema: { type: 'json' },
|
|
462
|
+
render: (_args, value) => [{ type: 'text', text: String((value as { content?: string }).content?.slice(0, 800) ?? JSON.stringify(value).slice(0, 800)) }],
|
|
463
|
+
},
|
|
464
|
+
async execute(args: { query: unknown }, _exec: unknown) {
|
|
465
|
+
const q = (args.query ?? {}) as { url?: string; timeoutMs?: number }
|
|
466
|
+
const started = Date.now()
|
|
467
|
+
if (!q.url) {
|
|
468
|
+
return JSON.parse(JSON.stringify({ ok: false, summary: 'url 必填', evidence: '[agent-reach:error] empty url' })) as Record<string, never>
|
|
469
|
+
}
|
|
470
|
+
const r = await readUrl({ url: q.url, timeoutMs: q.timeoutMs })
|
|
471
|
+
const dir = await ensureStateDir(config.stateRoot)
|
|
472
|
+
await recordLatency(join(dir, 'bridge-latency.jsonl'), Date.now() - started, `agent-reach:${r.via}`).catch(() => {})
|
|
473
|
+
const summary = r.ok
|
|
474
|
+
? `${q.url} → ${r.title ?? '(no title)'} (${r.latencyMs}ms)\n${r.evidence}\n---\n${r.content?.slice(0, 600) ?? ''}`
|
|
475
|
+
: `${q.url} → unavailable (${r.error})\n${r.evidence}`
|
|
476
|
+
return JSON.parse(JSON.stringify({
|
|
477
|
+
ok: r.ok, url: q.url, via: r.via, title: r.title,
|
|
478
|
+
content: r.content, evidence: r.evidence, summary,
|
|
479
|
+
latency_ms: Date.now() - started,
|
|
480
|
+
})) as Record<string, never>
|
|
481
|
+
},
|
|
482
|
+
presentCall: args => ({ card: 'generic', title: `读网页:${String((args.query as { url?: string })?.url ?? '')}`, kind: 'other', rawInput: args.query }),
|
|
483
|
+
}))
|
|
484
|
+
|
|
485
|
+
registerGuarded(defineTool({
|
|
486
|
+
name: 'gotry_video_subtitle',
|
|
487
|
+
description:
|
|
488
|
+
'Extract subtitles from a YouTube/Bilibili video (yt-dlp, optional tool). ' +
|
|
489
|
+
'If yt-dlp is installed on this machine, returns the subtitle text (vtt, zh-Hans/zh/en preference). ' +
|
|
490
|
+
'If NOT installed, degrades gracefully with install instructions — never blocks. ' +
|
|
491
|
+
'Evidence chain: [agent-reach:yt-dlp@ts] / [@not-installed@ts].',
|
|
492
|
+
parameters: {
|
|
493
|
+
query: {
|
|
494
|
+
type: 'json',
|
|
495
|
+
required: true,
|
|
496
|
+
description: '{ url: "https://www.youtube.com/watch?v=...", lang?: "zh-Hans,zh,en" }',
|
|
497
|
+
},
|
|
498
|
+
},
|
|
499
|
+
output: {
|
|
500
|
+
schema: { type: 'json' },
|
|
501
|
+
render: (_args, value) => [{ type: 'text', text: String((value as { summary?: string }).summary ?? JSON.stringify(value).slice(0, 600)) }],
|
|
502
|
+
},
|
|
503
|
+
async execute(args: { query: unknown }, _exec: unknown) {
|
|
504
|
+
const q = (args.query ?? {}) as { url?: string; lang?: string }
|
|
505
|
+
if (!q.url) {
|
|
506
|
+
return JSON.parse(JSON.stringify({ ok: false, summary: 'url 必填' })) as Record<string, never>
|
|
507
|
+
}
|
|
508
|
+
const r = await videoSubtitle({ url: q.url, lang: q.lang })
|
|
509
|
+
const summary = r.verdict === 'found'
|
|
510
|
+
? `${q.url} 字幕提取成功 (${r.latencyMs}ms)\n${r.evidence}\n---\n${(r.subtitles ?? '').slice(0, 800)}`
|
|
511
|
+
: r.verdict === 'not-installed'
|
|
512
|
+
? `yt-dlp 未安装。${r.stderr}\n${r.evidence}`
|
|
513
|
+
: `${q.url} 字幕提取失败(${r.verdict})\n${r.evidence}`
|
|
514
|
+
return JSON.parse(JSON.stringify({
|
|
515
|
+
ok: r.ok, verdict: r.verdict, url: q.url,
|
|
516
|
+
subtitles: r.subtitles?.slice(0, 4000), evidence: r.evidence, summary,
|
|
517
|
+
latency_ms: r.latencyMs,
|
|
518
|
+
})) as Record<string, never>
|
|
519
|
+
},
|
|
520
|
+
presentCall: args => ({ card: 'generic', title: `视频字幕:${String((args.query as { url?: string })?.url ?? '')}`, kind: 'other', rawInput: args.query }),
|
|
521
|
+
}))
|
|
522
|
+
|
|
523
|
+
registerGuarded(defineTool({
|
|
524
|
+
name: 'gotry_github_search',
|
|
525
|
+
description:
|
|
526
|
+
'Search GitHub repositories (gh CLI, optional tool). ' +
|
|
527
|
+
'If gh is installed and authenticated, returns repos with name/description/stars/url. ' +
|
|
528
|
+
'If NOT installed, degrades with install instructions — never blocks. ' +
|
|
529
|
+
'Evidence chain: [agent-reach:gh@ts] / [@not-installed@ts].',
|
|
530
|
+
parameters: {
|
|
531
|
+
query: {
|
|
532
|
+
type: 'json',
|
|
533
|
+
required: true,
|
|
534
|
+
description: '{ query: "agent-reach", limit?: 5 }',
|
|
535
|
+
},
|
|
536
|
+
},
|
|
537
|
+
output: {
|
|
538
|
+
schema: { type: 'json' },
|
|
539
|
+
render: (_args, value) => [{ type: 'text', text: String((value as { summary?: string }).summary ?? JSON.stringify(value).slice(0, 600)) }],
|
|
540
|
+
},
|
|
541
|
+
async execute(args: { query: unknown }, _exec: unknown) {
|
|
542
|
+
const q = (args.query ?? {}) as { query?: string; limit?: number }
|
|
543
|
+
if (!q.query) {
|
|
544
|
+
return JSON.parse(JSON.stringify({ ok: false, summary: 'query 必填' })) as Record<string, never>
|
|
545
|
+
}
|
|
546
|
+
const r = await githubSearch({ query: q.query, limit: q.limit })
|
|
547
|
+
const summary = r.verdict === 'found'
|
|
548
|
+
? `${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}`
|
|
549
|
+
: r.verdict === 'not-installed'
|
|
550
|
+
? `gh 未安装。${r.stderr}\n${r.evidence}`
|
|
551
|
+
: `${q.query} 搜索失败(${r.verdict})\n${r.evidence}`
|
|
552
|
+
return JSON.parse(JSON.stringify({
|
|
553
|
+
ok: r.ok, verdict: r.verdict, query: q.query,
|
|
554
|
+
repos: r.repos, evidence: r.evidence, summary,
|
|
555
|
+
latency_ms: r.latencyMs,
|
|
556
|
+
})) as Record<string, never>
|
|
557
|
+
},
|
|
558
|
+
presentCall: args => ({ card: 'generic', title: `GitHub 搜索:${String((args.query as { query?: string })?.query ?? '')}`, kind: 'other', rawInput: args.query }),
|
|
559
|
+
}))
|
|
560
|
+
|
|
561
|
+
registerGuarded(defineTool({
|
|
562
|
+
name: 'gotry_agent_reach',
|
|
563
|
+
description:
|
|
564
|
+
'Agent Reach — thin wrapper over Panniantong/Agent-Reach upstream registry (zero channel knowledge here). ' +
|
|
565
|
+
'Call ANY upstream channel method by reflection: web.read(url) / v2ex.get_hot_topics() / v2ex.search(query) / ' +
|
|
566
|
+
'xueqiu.get_stock_quote(symbol) / xueqiu.search_stock(query) / youtube.transcribe(url) / <channel>.check() ... ' +
|
|
567
|
+
'Unknown channel or method? Just call it — the error returns the upstream inventory (channel list or method signatures) so you can self-correct. ' +
|
|
568
|
+
'Action "status" runs the real `agent-reach doctor` (.venv/bin/agent-reach). ' +
|
|
569
|
+
'Channels needing cookies/setup return the upstream check() guidance verbatim (never blocks). ' +
|
|
570
|
+
'Evidence chain: [agent-reach:<channel>.<method>@ts].',
|
|
571
|
+
parameters: {
|
|
572
|
+
query: {
|
|
573
|
+
type: 'json',
|
|
574
|
+
required: true,
|
|
575
|
+
description: '{ action: "status" } 或 { action: "reach", channel: "<上游渠道名,如 web/v2ex/xueqiu>", method: "<上游方法名,如 read/get_hot_topics/get_stock_quote>", args?: "<空格分隔参数>" }',
|
|
576
|
+
},
|
|
577
|
+
},
|
|
578
|
+
output: {
|
|
579
|
+
schema: { type: 'json' },
|
|
580
|
+
render: (_args, value) => [{ type: 'text', text: String((value as { summary?: string }).summary ?? JSON.stringify(value).slice(0, 800)) }],
|
|
581
|
+
},
|
|
582
|
+
async execute(args: { query: unknown }, _exec: unknown) {
|
|
583
|
+
const q = (args.query ?? {}) as { action?: string; channel?: string; method?: string; args?: string; timeoutMs?: number }
|
|
584
|
+
const started = Date.now()
|
|
585
|
+
const dir = await ensureStateDir(config.stateRoot)
|
|
586
|
+
|
|
587
|
+
if (q.action === 'status' || (!q.action && !q.channel)) {
|
|
588
|
+
const st = await reachStatus(q.timeoutMs)
|
|
589
|
+
await recordLatency(join(dir, 'bridge-latency.jsonl'), Date.now() - started, 'agent-reach:doctor').catch(() => {})
|
|
590
|
+
const summary = st.via === 'agent-reach-cli'
|
|
591
|
+
? `Agent Reach doctor(上游 CLI,原样透传):\n${st.output}\n${st.evidence}`
|
|
592
|
+
: `Agent Reach 未装:\n${st.output}\n${st.evidence}`
|
|
593
|
+
return JSON.parse(JSON.stringify({
|
|
594
|
+
ok: st.ok, via: st.via, output: st.output, evidence: st.evidence, summary,
|
|
595
|
+
latency_ms: Date.now() - started,
|
|
596
|
+
})) as Record<string, never>
|
|
597
|
+
}
|
|
598
|
+
|
|
599
|
+
if (!q.channel || !q.method) {
|
|
600
|
+
return JSON.parse(JSON.stringify({ ok: false, summary: 'channel 与 method 必填(或 action=status);清单可先随便调一次,inventory 会带回上游渠道/方法表' })) as Record<string, never>
|
|
601
|
+
}
|
|
602
|
+
const r = await reach({ channel: q.channel, method: q.method, args: q.args, timeoutMs: q.timeoutMs })
|
|
603
|
+
await recordLatency(join(dir, 'bridge-latency.jsonl'), Date.now() - started, `agent-reach:${q.channel}.${q.method}:${r.verdict}`).catch(() => {})
|
|
604
|
+
const summary = r.verdict === 'found'
|
|
605
|
+
? `${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)}`
|
|
606
|
+
: r.verdict === 'needs-setup'
|
|
607
|
+
? `${q.channel}.${q.method} → 需配置(上游 check() 原话): ${r.setup ?? ''}\n${r.evidence}`
|
|
608
|
+
: r.verdict === 'not-installed'
|
|
609
|
+
? `${q.channel}.${q.method} → 上游未装: ${r.setup ?? ''}\n${r.evidence}`
|
|
610
|
+
: `${q.channel}.${q.method} → ${r.error ?? 'error'}${r.inventory ? `\n上游清单: ${JSON.stringify(r.inventory).slice(0, 1200)}` : ''}\n${r.evidence}`
|
|
611
|
+
return JSON.parse(JSON.stringify({
|
|
612
|
+
ok: r.ok, channel: r.channel, method: q.method, verdict: r.verdict,
|
|
613
|
+
data: typeof r.data === 'string' ? r.data.slice(0, 4000) : r.data,
|
|
614
|
+
inventory: r.inventory, setup: r.setup, evidence: r.evidence, summary,
|
|
615
|
+
latency_ms: Date.now() - started,
|
|
616
|
+
})) as Record<string, never>
|
|
617
|
+
},
|
|
618
|
+
presentCall: args => ({ card: 'generic', title: `Agent Reach:${String((args.query as { channel?: string; method?: string })?.channel ?? '')}.${String((args.query as { method?: string })?.method ?? 'status')}`, kind: 'other', rawInput: args.query }),
|
|
619
|
+
}))
|
|
620
|
+
}
|