@danceiny/gotry 0.0.1-rc.7 → 0.0.1-rc.9
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +29 -74
- package/bin/gotry-inner.js +50 -1
- package/bin/gotry-stdio-ask.js +59 -0
- package/cordis.gotry-patch.yml +63 -5
- package/data/flights_2026.json +7 -4
- package/data/hotels_2026.json +102 -15
- package/data/time-slot-eval.json +384 -0
- package/data/yunnan-pack.json +2 -1
- package/dist/capabilities/agent-reach-bridge.py +90 -0
- package/dist/capabilities/anything.js +2 -2
- package/dist/capabilities/flyai.js +131 -0
- package/dist/capabilities/hbcli.js +11 -6
- package/dist/capabilities/incident-log.js +2 -1
- package/dist/capabilities/session/action-cache.js +120 -0
- package/dist/capabilities/session/adapters/ctrip-flight.js +83 -0
- package/dist/capabilities/session/adapters/meituan-local.js +73 -0
- package/dist/capabilities/session/extract.js +40 -0
- package/dist/capabilities/session/read-guard.js +56 -0
- package/dist/capabilities/session/transport.js +95 -0
- package/dist/capabilities/session-search.js +95 -0
- package/dist/scripts/action-cache-tests.js +107 -0
- package/dist/scripts/agent-reach-tests.js +1 -1
- package/dist/scripts/agent-reach-wrapper-tests.js +1 -1
- package/dist/scripts/async-collect.js +26 -7
- package/dist/scripts/companion-tests.js +112 -0
- package/dist/scripts/hbcli-tests.js +18 -2
- package/dist/scripts/ledger-tests.js +344 -0
- package/dist/scripts/ledger-workflow-crash.js +42 -0
- package/dist/scripts/memory-capture-tests.js +77 -0
- package/dist/scripts/memory-decay-tests.js +83 -0
- package/dist/scripts/memory-metrics.js +24 -0
- package/dist/scripts/nudge-digest.js +83 -0
- package/dist/scripts/probe-poi-tests.js +9 -0
- package/dist/scripts/replay.js +73 -1
- package/dist/scripts/session-attach-diagnose.js +31 -0
- package/dist/scripts/session-attach-poc.js +65 -0
- package/dist/scripts/session-attach-wait.js +41 -0
- package/dist/scripts/session-extract-tests.js +81 -0
- package/dist/scripts/session-login.js +48 -0
- package/dist/scripts/session-tests.js +162 -0
- package/dist/scripts/skeleton-check.js +3 -1
- package/dist/scripts/skills-contract-tests.js +85 -0
- package/dist/scripts/smoke.js +242 -4
- package/dist/scripts/state-cli-tests.js +136 -0
- package/dist/scripts/state-cli.js +234 -0
- package/dist/scripts/time-eval-tests.js +318 -0
- package/dist/scripts/travel-timeline-tests.js +123 -0
- package/dist/scripts/unified-tests.js +1 -1
- package/dist/src/companions.js +112 -0
- package/dist/src/dsh-llm.js +27 -7
- package/dist/src/index.js +600 -121
- package/dist/src/loop.js +89 -25
- package/dist/src/memory-capture.js +40 -0
- package/dist/src/memory-decay.js +31 -0
- package/dist/src/memory-utility.js +55 -0
- package/dist/src/mock-llm.js +6 -1
- package/dist/src/slot-spec.js +165 -0
- package/dist/src/state-ledger.js +848 -0
- package/dist/src/time-anchor.js +100 -0
- package/dist/src/tool-packet.js +12 -0
- package/dist/src/travel-slots.js +144 -0
- package/dist/src/travel-timeline.js +78 -0
- package/dist/src/unified.js +3 -1
- package/dist/src/wish-pool.js +27 -0
- package/package.json +6 -2
- package/ts/capabilities/hbcli.ts +6 -6
- package/ts/capabilities/incident-log.ts +6 -3
- package/ts/package.json +3 -0
- package/ts/scripts/skeleton-check.ts +5 -1
- package/ts/src/dsh-llm.ts +27 -7
- package/ts/src/index.ts +439 -91
- package/ts/src/loop.ts +109 -34
- package/ts/src/mock-llm.ts +13 -1
- package/ts/src/unified.ts +6 -1
- package/ts/cordis.gotry-patch.yml +0 -36
package/ts/src/index.ts
CHANGED
|
@@ -14,20 +14,30 @@
|
|
|
14
14
|
*/
|
|
15
15
|
|
|
16
16
|
import { join } from 'node:path'
|
|
17
|
+
|
|
17
18
|
import type { Context } from '@deepseek-ai/cordis'
|
|
18
19
|
import z from '@deepseek-ai/schemastery'
|
|
19
20
|
import { defineTool } from '@deepseek-ai/dsh-tools'
|
|
20
|
-
import { ensureStateDir,
|
|
21
|
+
import { ensureStateDir, recordLatency } from './bridge.ts'
|
|
21
22
|
import { segmentsFromCandidate, solveChoiceSegment } from './unified.ts'
|
|
22
23
|
import { checkConnectivity } from '../scripts/skeleton-check.ts'
|
|
23
24
|
import { parseCandidate, parseRequest } from './model.ts'
|
|
24
25
|
import { searchHotels as hbcliSearchHotels } from '../capabilities/hbcli.ts'
|
|
25
26
|
import { installProcessGuards, guardToolExecute } from '../capabilities/incident-log.ts'
|
|
27
|
+
import { interpretArgs, type GotryObservation } from './tool-packet.ts'
|
|
28
|
+
import { projectUtility } from './memory-utility.ts'
|
|
29
|
+
import { pickNudgeWish, type WishPoolEntry } from './wish-pool.ts'
|
|
30
|
+
import { resolveTimelineDate } from './travel-timeline.ts'
|
|
31
|
+
import { ensureLedger, readCompanionsWithFallback, readMotivationWithFallback, readTripsWithFallback, readWishPoolWithFallback } from './state-ledger.ts'
|
|
32
|
+
import { buildTimeAnchor } from './time-anchor.ts'
|
|
33
|
+
import { resolveSlotDate } from './slot-spec.ts'
|
|
26
34
|
import { geocodePlace, getForecast, getClimate, wmoLabel } from '../capabilities/weather.ts'
|
|
27
35
|
import { verifyFlight } from '../capabilities/opensky.ts'
|
|
28
36
|
import { anythingSearch } from '../capabilities/anything.ts'
|
|
29
37
|
import { readUrl, reach, reachStatus } from '../capabilities/agent-reach.ts'
|
|
30
38
|
import { videoSubtitle, githubSearch } from '../capabilities/agent-reach-deep.ts'
|
|
39
|
+
import { flyaiSearch } from '../capabilities/flyai.ts'
|
|
40
|
+
import { sessionFlightSearch } from '../capabilities/session-search.ts'
|
|
31
41
|
|
|
32
42
|
export const name = 'gotry-tools'
|
|
33
43
|
export const inject = ['tools', 'systemPrompt']
|
|
@@ -49,6 +59,8 @@ export const Config: z<Config> = z.object({
|
|
|
49
59
|
|
|
50
60
|
interface FeasibilityResult {
|
|
51
61
|
answer_md?: string
|
|
62
|
+
recommended?: string | null
|
|
63
|
+
verdicts?: Array<Record<string, unknown>>
|
|
52
64
|
}
|
|
53
65
|
|
|
54
66
|
interface MotivationProfileInput {
|
|
@@ -63,6 +75,58 @@ interface WishPoolEntryInput {
|
|
|
63
75
|
conditions?: Record<string, unknown>
|
|
64
76
|
}
|
|
65
77
|
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* query 包装参数的三形态归一(#12/#13):实现移居 tool-packet.ts(interpretArgs,
|
|
81
|
+
* RFC S1 interpretation 层语义归位),此处按旧名引 handy 别名,调用点零漂移。
|
|
82
|
+
*/
|
|
83
|
+
const unwrapQuery = interpretArgs
|
|
84
|
+
|
|
85
|
+
/**
|
|
86
|
+
* 动机画像 → persona 紧凑 brief(M4 T1 读回路径):空画像返回 ''(首访),
|
|
87
|
+
* persona 据此决定是否访谈。只读,不猜——画像里没有的字段不编。
|
|
88
|
+
* ADR-15:读经账本(未迁移 root 回退旧文件,只读)。
|
|
89
|
+
*/
|
|
90
|
+
function renderMotivationBrief(stateRoot: string): string {
|
|
91
|
+
const p = readMotivationWithFallback(stateRoot)
|
|
92
|
+
if (!p) return '' // 首访:无画像
|
|
93
|
+
const lines: string[] = ['## 用户记忆(跨会话画像;与用户当轮说法冲突时以用户为准,更新经 gotry_motivation_save)']
|
|
94
|
+
const weights = Object.entries(p.weights ?? {})
|
|
95
|
+
if (weights.length) lines.push(`- 动机权重: ${weights.map(([k, v]) => `${k}=${v}`).join(', ')}(证据 ${p.evidence?.length ?? 0} 条)`)
|
|
96
|
+
const hard = Object.entries(p.hard ?? {})
|
|
97
|
+
if (hard.length) lines.push(`- 硬约束: ${hard.map(([k, v]) => `${k}=${String(v)}`).join(', ')}`)
|
|
98
|
+
// 旅行时间线(memory-design P1):去过的地方不再主动推荐,除非用户点名
|
|
99
|
+
const trips = readTimelineTrips(stateRoot)
|
|
100
|
+
if (trips.length) {
|
|
101
|
+
lines.push(`- 去过: ${trips.map(t => `${t.destination}(${t.start.slice(0, 7)})`).join('、')}——去过的地方不再主动推荐,除非用户点名`)
|
|
102
|
+
}
|
|
103
|
+
// 同行人档案(memory-design P2):约束只进排序与行程结构建议,永不硬过滤
|
|
104
|
+
const companions = readCompanions(stateRoot)
|
|
105
|
+
if (companions.length) {
|
|
106
|
+
lines.push(`- 同行人: ${companions.map(c => `${c.label}(${c.brief})`).join('、')}——约束进结构与排序建议,不硬过滤;引用时带当初的原话依据`)
|
|
107
|
+
}
|
|
108
|
+
lines.push(`- 愿望池: 用 gotry_wish_pool_list 按条件召回(0..1),勿直接堆砌`)
|
|
109
|
+
if (p.updated_at) lines.push(`- 更新于: ${p.updated_at}`)
|
|
110
|
+
return weights.length || hard.length ? lines.join('\n') : ''
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/** 时间线摘要(brief 用):最近 3 次行程(目的地+年月);账本优先,未迁移回退文件 */
|
|
114
|
+
/** 同行人摘要(brief 用):label + 约束串 */
|
|
115
|
+
function readCompanions(stateRoot: string): Array<{ label: string; brief: string }> {
|
|
116
|
+
return readCompanionsWithFallback(stateRoot)
|
|
117
|
+
.map(c => {
|
|
118
|
+
const bits = [c.constraints.mobility, ...(c.constraints.health ?? []), ...(c.constraints.prefs ?? [])].filter(Boolean)
|
|
119
|
+
return { label: c.label, brief: bits.join('/') }
|
|
120
|
+
})
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
function readTimelineTrips(stateRoot: string): Array<{ destination: string; start: string }> {
|
|
124
|
+
return readTripsWithFallback(stateRoot)
|
|
125
|
+
.map(t => ({ destination: t.destination, start: t.start }))
|
|
126
|
+
.sort((a, b) => (a.start < b.start ? 1 : -1))
|
|
127
|
+
.slice(0, 3)
|
|
128
|
+
}
|
|
129
|
+
|
|
66
130
|
type Json = string | number | boolean | null | Json[] | { [k: string]: Json }
|
|
67
131
|
type JsonObject = { [k: string]: Json }
|
|
68
132
|
|
|
@@ -78,6 +142,14 @@ export function apply(ctx: Context, config: Config): void {
|
|
|
78
142
|
const weekdays = ['日', '一', '二', '三', '四', '五', '六']
|
|
79
143
|
return `${ymd} 周${weekdays[d.getDay()]}`
|
|
80
144
|
})
|
|
145
|
+
// 时间锚点卡(time-anchor 层,确定性):相对日期换算的唯一依据——
|
|
146
|
+
// 明天/下周X/下个月中旬/节日都查卡,LLM 不自算(算术只在代码层)。
|
|
147
|
+
sp?.variable?.('time_anchor_card', () => buildTimeAnchor(new Date()).card)
|
|
148
|
+
|
|
149
|
+
// M4 记忆读回(T1 闭环的另一半):motivation-profile 此前只写不读,每个新会话
|
|
150
|
+
// 模型都是盲的、重新访谈——「回访规划时长降 ≥50%」不可能成立。这里把画像
|
|
151
|
+
// 渲染成紧凑 brief 注入 persona;为空 = 首访。与当轮说法冲突时以用户为准。
|
|
152
|
+
sp?.variable?.('motivation_brief', () => renderMotivationBrief(config.stateRoot ?? '.'))
|
|
81
153
|
|
|
82
154
|
// D-NEW 进程护栏(Z3 WASM crash 教训):dsh 0.1.1-rc.1 缺 uncaughtException
|
|
83
155
|
// handler,插件异常穿透即杀进程。我们在 gotry 侧挂护栏:同步 fsync 写事故证据
|
|
@@ -101,7 +173,8 @@ export function apply(ctx: Context, config: Config): void {
|
|
|
101
173
|
'Check travel candidates against the user\'s motivation and hard constraints using the '
|
|
102
174
|
+ 'door-to-door true-cost engine (wake time, arrival state, energy, usable hours, money). '
|
|
103
175
|
+ 'Input is the structured request (motivation weights + hard constraints + window + budget + home hubs) '
|
|
104
|
-
+ 'and the candidate list (services/transfers/stay costs/min days)
|
|
176
|
+
+ 'and the candidate list (services/transfers/stay costs/min days): '
|
|
177
|
+
+ 'structure { request: { motivation weights, hard constraints, window, budget, home hubs }, candidates: [ { id, label, services, transfers, stay, minDays } ] }. '
|
|
105
178
|
+ 'Returns per-candidate verdicts, unsat cores with minimal-modification suggestions, '
|
|
106
179
|
+ 'a wish-pool entry for infeasible aspirations, and a ready-to-show markdown answer.',
|
|
107
180
|
parameters: {
|
|
@@ -129,9 +202,28 @@ export function apply(ctx: Context, config: Config): void {
|
|
|
129
202
|
const result = solveChoiceSegment(spec, req) as Record<string, unknown>
|
|
130
203
|
const dir = await ensureStateDir(config.stateRoot)
|
|
131
204
|
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' }
|
|
205
|
+
return { ok: true, ...result, latency_ms: Date.now() - started, via: 'in-process-unified' }
|
|
206
|
+
},
|
|
207
|
+
presentCall: args => ({ card: 'generic', title: 'GoTry 可行性检查(门到门全成本)', kind: 'execute', rawInput: args.payload }),
|
|
208
|
+
presentResult: (args, value) => {
|
|
209
|
+
// D-4 卡片赎回:结果卡不再是裸 JSON —— 逐候选判定 + 全成本 vs 预算紧凑行 + 人话答案
|
|
210
|
+
const r = value as FeasibilityResult
|
|
211
|
+
const budget = ((args.payload as { request?: { budget_cny?: number } })?.request)?.budget_cny
|
|
212
|
+
const lines = (r.verdicts ?? []).map(v => {
|
|
213
|
+
const tc = (v.true_cost ?? {}) as { money_cny?: number }
|
|
214
|
+
const money = typeof tc.money_cny === 'number'
|
|
215
|
+
? ` ¥${tc.money_cny}/人` + (typeof budget === 'number' ? `(预算 ¥${budget},${tc.money_cny <= budget ? '余' : '超'} ¥${Math.abs(budget - tc.money_cny)})` : '')
|
|
216
|
+
: ''
|
|
217
|
+
return v.feasible
|
|
218
|
+
? `✅ ${String(v.name ?? v.candidate_id)}${money}${v.candidate_id === r.recommended ? ' ← 推荐' : ''}`
|
|
219
|
+
: `❌ ${String(v.name ?? v.candidate_id)} — ${(Array.isArray(v.unsat_core) ? v.unsat_core.join(',') : '不可行')}`
|
|
220
|
+
})
|
|
221
|
+
return {
|
|
222
|
+
card: 'generic',
|
|
223
|
+
title: `可行性:${r.recommended ? `推荐 ${r.recommended}` : '全部不可行'}`,
|
|
224
|
+
content: [{ type: 'text', text: (lines.length ? lines.join('\n') + '\n\n' : '') + String(r.answer_md ?? '').slice(0, 1500) }],
|
|
225
|
+
}
|
|
133
226
|
},
|
|
134
|
-
presentCall: args => ({ card: 'generic', title: 'GoTry 可行性检查(门到门全成本)', kind: 'other', rawInput: args.payload }),
|
|
135
227
|
}))
|
|
136
228
|
|
|
137
229
|
registerGuarded(defineTool({
|
|
@@ -139,8 +231,9 @@ export function apply(ctx: Context, config: Config): void {
|
|
|
139
231
|
description:
|
|
140
232
|
'Persist the traveler\'s motivation profile (the "why depart" contract object). '
|
|
141
233
|
+ 'This is the B2B reuse seam: downstream plugins consume only MotivationProfile + constraints, '
|
|
142
|
-
+ 'never the principal/sponsor distinction.
|
|
143
|
-
+ '
|
|
234
|
+
+ 'never the principal/sponsor distinction. MERGE semantics (T1): call again with just the NEW '
|
|
235
|
+
+ 'facts learned this turn (weights delta optional but MUST bring fresh evidence; evidence = user quotes); '
|
|
236
|
+
+ 'existing history is never deleted. Requires evidence on every call (P0 anti-fabrication rule).',
|
|
144
237
|
parameters: {
|
|
145
238
|
profile: {
|
|
146
239
|
type: 'json',
|
|
@@ -149,30 +242,27 @@ export function apply(ctx: Context, config: Config): void {
|
|
|
149
242
|
},
|
|
150
243
|
},
|
|
151
244
|
output: {
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
additionalProperties: false,
|
|
155
|
-
properties: {
|
|
156
|
-
saved: { type: 'boolean' },
|
|
157
|
-
path: { type: 'string' },
|
|
158
|
-
profile: { type: 'json' },
|
|
159
|
-
},
|
|
160
|
-
},
|
|
245
|
+
// loose json:guard 兜底字段(ok/summary)不被 strict schema 拒(骨架事故同款)
|
|
246
|
+
schema: { type: 'json' },
|
|
161
247
|
render: (_args, value) => [{ type: 'text', text: `动机画像已保存:${String((value as { path?: string }).path ?? '')}` }],
|
|
162
248
|
},
|
|
163
249
|
async execute(args: { profile: unknown }, _exec: unknown) {
|
|
164
|
-
|
|
165
|
-
|
|
250
|
+
// T1 接线:本工具是增量补丁语义(契约 18——模型每轮把对话新事实带进来),
|
|
251
|
+
// 经 mergeProfile 守门合并进既有画像(追加不删史/幂等/权重变更须伴新证据),
|
|
252
|
+
// 不再整档覆盖。首次调用(无档案)= 全量建立。
|
|
253
|
+
// ADR-15:守门+事件+投影在账本单事务内完成,evidence 红线拒绝即回滚,账本无痕。
|
|
254
|
+
const incoming = (args.profile ?? {}) as { weights?: Record<string, number>; evidence?: string[]; hard?: Record<string, unknown> }
|
|
255
|
+
if (!incoming.evidence?.length) {
|
|
166
256
|
throw new Error('refusing to save a motivation profile without evidence (P0 anti-fabrication rule)')
|
|
167
257
|
}
|
|
168
|
-
const
|
|
169
|
-
const
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
},
|
|
175
|
-
presentCall: args => ({ card: 'generic', title: '保存动机画像', kind: '
|
|
258
|
+
const ledger = ensureLedger(config.stateRoot)
|
|
259
|
+
const res = ledger.appendMotivationPatch({ weights: incoming.weights, evidence: incoming.evidence, hard: incoming.hard })
|
|
260
|
+
const profileJson = JSON.parse(JSON.stringify(res.profile)) as JsonObject
|
|
261
|
+
return res.saved
|
|
262
|
+
? { ok: true, saved: true, path: ledger.dbPath, profile: profileJson, summary: '画像已合并入账本(单事务)' }
|
|
263
|
+
: { ok: true, saved: false, path: ledger.dbPath, profile: profileJson, summary: '无新内容(幂等跳过)' }
|
|
264
|
+
},
|
|
265
|
+
presentCall: args => ({ card: 'generic', title: '保存动机画像', kind: 'edit', rawInput: args.profile }),
|
|
176
266
|
}))
|
|
177
267
|
|
|
178
268
|
registerGuarded(defineTool({
|
|
@@ -180,62 +270,184 @@ export function apply(ctx: Context, config: Config): void {
|
|
|
180
270
|
description:
|
|
181
271
|
'Add an aspiration to the "next departure" wish pool — the graceful home for infeasible dreams. '
|
|
182
272
|
+ 'An entry carries its fulfilment conditions (days needed, budget, best months) so a future '
|
|
183
|
-
+ '"next departure" nudge can fire when the window matches.
|
|
273
|
+
+ '"next departure" nudge can fire when the window matches. Each entry gets a stable wish_id '
|
|
274
|
+
+ '(the memory-utility sidecar keys on it); muted:true puts a wish dormant (永不删除,只是不再召回). 憧憬不被拒绝。',
|
|
184
275
|
parameters: {
|
|
185
276
|
entry: {
|
|
186
277
|
type: 'json',
|
|
187
278
|
required: true,
|
|
188
|
-
description: '{ name, reason
|
|
279
|
+
description: '{ name, reason?, conditions: { days, budget_cny, best_months }, muted?: boolean }',
|
|
189
280
|
},
|
|
190
281
|
},
|
|
191
282
|
output: {
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
additionalProperties: false,
|
|
195
|
-
properties: {
|
|
196
|
-
added: { type: 'boolean' },
|
|
197
|
-
total: { type: 'integer' },
|
|
198
|
-
path: { type: 'string' },
|
|
199
|
-
},
|
|
200
|
-
},
|
|
283
|
+
// loose json:guard 兜底字段(ok/summary)不被 strict schema 拒(骨架事故同款)
|
|
284
|
+
schema: { type: 'json' },
|
|
201
285
|
render: (_args, value) => {
|
|
202
286
|
const v = value as { total?: number; path?: string }
|
|
203
287
|
return [{ type: 'text', text: `已加入「下一次出发」清单(共 ${v.total ?? '?'} 项):${v.path ?? ''}` }]
|
|
204
288
|
},
|
|
205
289
|
},
|
|
206
290
|
async execute(args: { entry: unknown }, _exec: unknown) {
|
|
207
|
-
const entry = (args.entry ?? {}) as WishPoolEntryInput
|
|
208
|
-
|
|
209
|
-
|
|
291
|
+
const entry = (args.entry ?? {}) as WishPoolEntryInput & { muted?: boolean }
|
|
292
|
+
// 同名憧憬幂等更新(刷新理由与成行条件),不重复入池;wish_id 语义派生自名称,稳定可重放。
|
|
293
|
+
// ADR-15:conditions 红线在账本事务内校验拒绝。
|
|
294
|
+
const ledger = ensureLedger(config.stateRoot)
|
|
295
|
+
const r = ledger.appendWish({ name: entry.name, reason: entry.reason, conditions: entry.conditions, muted: entry.muted })
|
|
296
|
+
return { ok: true, added: r.added, wish_id: r.wish_id, total: r.total, path: ledger.dbPath }
|
|
297
|
+
},
|
|
298
|
+
presentCall: args => ({ card: 'generic', title: '加入「下一次出发」清单', kind: 'edit', rawInput: args.entry }),
|
|
299
|
+
}))
|
|
300
|
+
|
|
301
|
+
registerGuarded(defineTool({
|
|
302
|
+
name: 'gotry_wish_pool_list',
|
|
303
|
+
description:
|
|
304
|
+
'Surface AT MOST ONE "next departure" wish whose fulfilment conditions match the user\'s current window '
|
|
305
|
+
+ '(0..1 rule: never more than one nudge per turn, never push when nothing matches — 憧憬不被拒绝,也不被硬推). '
|
|
306
|
+
+ 'Muted wishes never surface. Surfacing records a recalled event in the memory-utility sidecar. '
|
|
307
|
+
+ 'action="confirm-outcome" records the user-confirmed real-world outcome (attribution helpful/harmful/neutral) — '
|
|
308
|
+
+ 'ONLY pass attribution the user explicitly stated; the agent must never self-attribute usefulness.',
|
|
309
|
+
parameters: {
|
|
310
|
+
query: {
|
|
311
|
+
type: 'json',
|
|
312
|
+
required: true,
|
|
313
|
+
description: '{ action?: "recall"|"confirm-outcome", days?, budgetCny?, month?, wishId?, attribution?: "helpful"|"harmful"|"neutral", detail?, tripStart?: "行程起始(确认成行时挂时间线)" }',
|
|
314
|
+
},
|
|
315
|
+
},
|
|
316
|
+
output: {
|
|
317
|
+
schema: { type: 'json' },
|
|
318
|
+
render: (_args, value) => [{ type: 'text', text: String((value as { summary?: string }).summary ?? '') }],
|
|
319
|
+
},
|
|
320
|
+
async execute(args: { query: unknown }, _exec: unknown) {
|
|
321
|
+
const q = unwrapQuery<{ action?: string; days?: number; budgetCny?: number; month?: number; wishId?: string; attribution?: 'helpful' | 'harmful' | 'neutral'; detail?: string; tripStart?: string }>(args, 'action')
|
|
322
|
+
const ledger = ensureLedger(config.stateRoot)
|
|
323
|
+
const now = new Date().toISOString()
|
|
324
|
+
if (q.action === 'confirm-outcome') {
|
|
325
|
+
if (!q.wishId || !q.attribution) {
|
|
326
|
+
return { ok: false, summary: 'confirm-outcome 需要 wishId + attribution(helpful|harmful|neutral)' } as never
|
|
327
|
+
}
|
|
328
|
+
// 成行确认 = 效用事件 + 可选时间线行程,账本单事务(ADR-15:原两文件写在崩溃时分叉)
|
|
329
|
+
let tripInput: { destination: string; start: string; end?: string; companions?: string[]; source: 'wish-confirmed'; evidence: string } | undefined
|
|
330
|
+
const wish = ledger.readWishPool().find(e => String(e.wish_id) === q.wishId) as { name?: unknown } | undefined
|
|
331
|
+
if (q.tripStart && wish?.name) {
|
|
332
|
+
const anchor = buildTimeAnchor(new Date())
|
|
333
|
+
const start = resolveTimelineDate(q.tripStart, anchor) ?? (/^\d{4}-\d{2}-\d{2}$/.test(q.tripStart) ? q.tripStart : undefined)
|
|
334
|
+
if (start) {
|
|
335
|
+
tripInput = {
|
|
336
|
+
destination: String(wish.name), start, companions: undefined,
|
|
337
|
+
source: 'wish-confirmed', evidence: `wish ${q.wishId} confirm-outcome(${q.attribution})`,
|
|
338
|
+
}
|
|
339
|
+
}
|
|
340
|
+
}
|
|
341
|
+
const r = ledger.confirmOutcome({ wishId: q.wishId, attribution: q.attribution, detail: q.detail, trip: tripInput })
|
|
342
|
+
return { ok: true, recorded: r.recorded, wish_id: q.wishId, status: q.attribution, ...(r.trip ? { trip: r.trip } : {}) } as never
|
|
210
343
|
}
|
|
211
|
-
|
|
212
|
-
const
|
|
213
|
-
const
|
|
214
|
-
|
|
215
|
-
const
|
|
216
|
-
if (
|
|
217
|
-
|
|
218
|
-
await writeJson(path, pool)
|
|
219
|
-
return { added: false, total: pool.length, path }
|
|
344
|
+
// recall:0..1 条件匹配(判定归 wish-pool 纯函数),muted 永不召回,无命中不硬推
|
|
345
|
+
const pool = ledger.readWishPool()
|
|
346
|
+
const candidates = pool.filter(e => !e.muted && typeof e.wish_id === 'string')
|
|
347
|
+
const month = q.month ?? new Date().getMonth() + 1
|
|
348
|
+
const match = pickNudgeWish(candidates as WishPoolEntry[], { days: q.days, budgetCny: q.budgetCny, month })
|
|
349
|
+
if (!match) {
|
|
350
|
+
return { ok: true, suggestion: null, summary: `无可成行的憧憬匹配当前窗口(${candidates.length} 条在册,0..1 纪律:不硬推)` } as never
|
|
220
351
|
}
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
352
|
+
const { events: next } = ledger.appendUtilityEvent({
|
|
353
|
+
wish_id: match.wishId, kind: 'recalled', ts: now, ctx: 'gotry_wish_pool_list.recall',
|
|
354
|
+
})
|
|
355
|
+
const utility = projectUtility(next)[match.wishId]
|
|
356
|
+
return {
|
|
357
|
+
ok: true,
|
|
358
|
+
suggestion: { wish_id: match.entry['wish_id'], name: match.entry['name'], reason: match.entry['reason'], conditions: match.entry['conditions'], match_score: match.score, hits: match.hits },
|
|
359
|
+
utility: { status: utility?.status ?? 'unknown', recalled: utility?.recalled ?? 1 },
|
|
360
|
+
summary: `「下一次出发」候选(0..1):${String(match.entry['name'])}——成行条件 ${JSON.stringify(match.entry['conditions'])},本次窗口命中 ${match.score}/3 项(${match.hits.join('+')});效用状态 ${utility?.status ?? 'unknown'}`,
|
|
361
|
+
} as never
|
|
362
|
+
},
|
|
363
|
+
presentCall: args => ({ card: 'generic', title: '「下一次出发」召回', kind: 'search', rawInput: args.query }),
|
|
364
|
+
}))
|
|
365
|
+
|
|
366
|
+
registerGuarded(defineTool({
|
|
367
|
+
name: 'gotry_companion_save',
|
|
368
|
+
description:
|
|
369
|
+
'Save/update a travel companion profile (memory-design M5 layer): constraints that shape itinerary STRUCTURE and ranking — '
|
|
370
|
+
+ 'never a hard filter (「爸爸65轻度高血压」→ 不排高海拔/控制步行量;「晕车」→ 优先火车/备提示). '
|
|
371
|
+
+ 'evidence MUST be the user\'s verbatim words (append-only, traceable). '
|
|
372
|
+
+ 'NEGATIVE LIST: passport/ID/phone numbers are rejected on sight — such fields never enter storage.',
|
|
373
|
+
parameters: {
|
|
374
|
+
companion: {
|
|
375
|
+
type: 'json',
|
|
376
|
+
required: true,
|
|
377
|
+
description: '{ label: "爸爸", constraints: { mobility?: "步行≤4h", health?: ["轻度高血压"], prefs?: ["怕吵"] }, evidence: "<用户原话>" }',
|
|
378
|
+
},
|
|
379
|
+
},
|
|
380
|
+
output: {
|
|
381
|
+
schema: { type: 'json' },
|
|
382
|
+
render: (_args, value) => [{ type: 'text', text: String((value as { summary?: string }).summary ?? '') }],
|
|
383
|
+
},
|
|
384
|
+
async execute(args: { companion: unknown }, _exec: unknown) {
|
|
385
|
+
const c = (args.companion ?? {}) as { label?: string; constraints?: { mobility?: string; health?: string[]; prefs?: string[] }; evidence?: string }
|
|
386
|
+
if (!c.evidence) return { ok: false, summary: 'evidence 必填(用户原话,溯源 P0)' } as never
|
|
387
|
+
// ADR-15:负面清单守卫(upsertCompanion)在账本事务内执行,拒收即回滚
|
|
388
|
+
const ledger = ensureLedger(config.stateRoot)
|
|
389
|
+
const res = ledger.appendCompanion({
|
|
390
|
+
label: String(c.label ?? ''),
|
|
391
|
+
constraints: { mobility: c.constraints?.mobility, health: c.constraints?.health, prefs: c.constraints?.prefs },
|
|
392
|
+
evidence: c.evidence,
|
|
393
|
+
})
|
|
394
|
+
if (!res.appended && res.reason) return { ok: false, summary: res.reason } as never
|
|
395
|
+
return { ok: true, appended: res.appended, companion_id: res.companionId, total: res.total, summary: res.appended ? `同行人已保存:${res.companionId}` : `无新内容(幂等跳过):${res.companionId}` } as never
|
|
396
|
+
},
|
|
397
|
+
presentCall: args => ({ card: 'generic', title: '保存同行人', kind: 'edit', rawInput: args.companion }),
|
|
398
|
+
}))
|
|
399
|
+
|
|
400
|
+
registerGuarded(defineTool({
|
|
401
|
+
name: 'gotry_trip_log',
|
|
402
|
+
description:
|
|
403
|
+
'Record a PAST trip into the travel timeline (memory-design M4 layer). Use when the user mentions a trip they already took '
|
|
404
|
+
+ '(「去年国庆去了大理」) — dates resolve via the time anchor (词表外日期会拒绝,请用户给绝对日期), evidence MUST be the user\'s verbatim words. '
|
|
405
|
+
+ 'Append-only and idempotent (same destination+start+source = same trip); overlapping same-destination trips are rejected for human adjudication. '
|
|
406
|
+
+ 'Past trips power origin resolution and 「去过不再推」 ranking — never a hard filter.',
|
|
407
|
+
parameters: {
|
|
408
|
+
query: {
|
|
409
|
+
type: 'json',
|
|
410
|
+
required: true,
|
|
411
|
+
description: '{ destination: "大理", start: "2025-10-01 或绝对日期表达", end?: 同上, companions?: ["爸爸"], evidence: "<用户原话>" }',
|
|
412
|
+
},
|
|
224
413
|
},
|
|
225
|
-
|
|
414
|
+
output: {
|
|
415
|
+
schema: { type: 'json' },
|
|
416
|
+
render: (_args, value) => [{ type: 'text', text: String((value as { summary?: string }).summary ?? '') }],
|
|
417
|
+
},
|
|
418
|
+
async execute(args: { query: unknown }, _exec: unknown) {
|
|
419
|
+
const q = unwrapQuery<{ destination?: string; start?: string; end?: string; companions?: string[]; evidence?: string }>(args, 'destination')
|
|
420
|
+
if (!q.destination) return { ok: false, summary: 'destination 必填' } as never
|
|
421
|
+
if (!q.evidence) return { ok: false, summary: 'evidence 必填(用户原话,溯源 P0)' } as never
|
|
422
|
+
const anchor = buildTimeAnchor(new Date())
|
|
423
|
+
const start = resolveTimelineDate(q.start ?? '', anchor) ?? (/^\d{4}-\d{2}-\d{2}$/.test(q.start ?? '') ? q.start : undefined)
|
|
424
|
+
if (!start) return { ok: false, summary: `start 无法解析为绝对日期(${q.start ?? '缺'})——请向用户要具体日期,不猜` } as never
|
|
425
|
+
const end = q.end ? (resolveTimelineDate(q.end, anchor) ?? (/^\d{4}-\d{2}-\d{2}$/.test(q.end) ? q.end : undefined)) : undefined
|
|
426
|
+
// ADR-15:appendTrip 守门(必填/绝对日期/重叠冲突即停)在账本事务内执行
|
|
427
|
+
const ledger = ensureLedger(config.stateRoot)
|
|
428
|
+
const res = ledger.appendTripEvent({
|
|
429
|
+
destination: q.destination.trim(), start, end, companions: q.companions,
|
|
430
|
+
source: 'user-verbatim', evidence: q.evidence,
|
|
431
|
+
})
|
|
432
|
+
if (!res.appended && res.reason) return { ok: false, summary: res.reason } as never
|
|
433
|
+
return { ok: true, appended: res.appended, trip_id: res.tripId, total: res.total, summary: res.appended ? `已入时间线:${q.destination} @ ${start}` : `已存在(幂等跳过):${res.tripId}` } as never
|
|
434
|
+
},
|
|
435
|
+
presentCall: args => ({ card: 'generic', title: '记录旅行', kind: 'edit', rawInput: args.query }),
|
|
226
436
|
}))
|
|
227
437
|
|
|
228
438
|
registerGuarded(defineTool({
|
|
229
439
|
name: 'gotry_hotel_search',
|
|
230
440
|
description:
|
|
231
441
|
'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.
|
|
442
|
+
+ 'Input: destination city name + optional dates/occupancy. Dates accept verbatim natural expressions (下周五 / 8.20 / 下周五+3) — '
|
|
443
|
+
+ 'the code layer resolves them against the time anchor; unresolved expressions degrade to an undated search with an explicit '
|
|
444
|
+
+ 'date_notes entry instead of guessing. Output: hotel list with evidence chain ([realtime-API:hbcli] + fetch timestamp, '
|
|
233
445
|
+ 'or [static-pack:estimate]) per the L4 invariant.',
|
|
234
446
|
parameters: {
|
|
235
447
|
query: {
|
|
236
448
|
type: 'json',
|
|
237
449
|
required: true,
|
|
238
|
-
description: '{ destination: "
|
|
450
|
+
description: '{ destination: "<目的地城市>", checkIn?: "YYYY-MM-DD 或自然表达(下周五/8.20)", checkOut?: 同上, occupancy?: { adults: 2 } }',
|
|
239
451
|
},
|
|
240
452
|
},
|
|
241
453
|
output: {
|
|
@@ -243,12 +455,26 @@ export function apply(ctx: Context, config: Config): void {
|
|
|
243
455
|
render: (_args, value) => [{ type: 'text', text: String((value as { summary?: string }).summary ?? JSON.stringify(value).slice(0, 400)) }],
|
|
244
456
|
},
|
|
245
457
|
async execute(args: { query: unknown }, _exec: unknown) {
|
|
246
|
-
const q =
|
|
458
|
+
const q = unwrapQuery<{ destination?: string; checkIn?: string; checkOut?: string; adults?: number }>(args, 'destination')
|
|
247
459
|
if (!q.destination) throw new Error('gotry_hotel_search requires destination')
|
|
248
460
|
const started = Date.now()
|
|
249
461
|
const fallbackPath = join(import.meta.dirname, '..', '..', 'data', 'hotels_2026.json')
|
|
462
|
+
// D-10 切片 B:日期槽位接受逐字自然表达(下周五/8.20/+N),代码层换算(slot-spec);
|
|
463
|
+
// unresolved 不猜——降级为无日期搜索并显式记 note,由模型向用户追问
|
|
464
|
+
const anchor = buildTimeAnchor(new Date())
|
|
465
|
+
const dateNotes: string[] = []
|
|
466
|
+
const resolveDate = (expr?: string): string | undefined => {
|
|
467
|
+
if (!expr) return undefined
|
|
468
|
+
const r = resolveSlotDate(expr, anchor)
|
|
469
|
+
if (!r.date) {
|
|
470
|
+
dateNotes.push(`日期未解析:${r.raw}——请向用户确认具体日期`)
|
|
471
|
+
return undefined
|
|
472
|
+
}
|
|
473
|
+
if (r.raw !== r.date) dateNotes.push(`slot-resolved: ${r.raw} → ${r.date}`)
|
|
474
|
+
return r.date
|
|
475
|
+
}
|
|
250
476
|
const resp = await hbcliSearchHotels(
|
|
251
|
-
{ destination: q.destination, checkIn: q.checkIn, checkOut: q.checkOut, adults: q.adults },
|
|
477
|
+
{ destination: q.destination, checkIn: resolveDate(q.checkIn), checkOut: resolveDate(q.checkOut), adults: q.adults },
|
|
252
478
|
{ hbcliBin: config.hbcliBin, timeoutMs: config.timeoutMs, fallbackPath },
|
|
253
479
|
)
|
|
254
480
|
const dir = await ensureStateDir(config.stateRoot)
|
|
@@ -256,6 +482,7 @@ export function apply(ctx: Context, config: Config): void {
|
|
|
256
482
|
const evidence = isLive ? resp.evidence : '[静态包:估算]'
|
|
257
483
|
await recordLatency(join(dir, 'bridge-latency.jsonl'), Date.now() - started, `hotel_search:${resp.via}`).catch(() => {})
|
|
258
484
|
const payload = {
|
|
485
|
+
ok: true,
|
|
259
486
|
hotels: resp.hotels ?? null,
|
|
260
487
|
evidence,
|
|
261
488
|
destination: q.destination,
|
|
@@ -263,10 +490,20 @@ export function apply(ctx: Context, config: Config): void {
|
|
|
263
490
|
latency_ms: Date.now() - started,
|
|
264
491
|
summary: resp.summary,
|
|
265
492
|
error: resp.error,
|
|
493
|
+
...(dateNotes.length ? { date_notes: dateNotes } : {}),
|
|
494
|
+
} as never
|
|
495
|
+
return JSON.parse(JSON.stringify(payload)) as never
|
|
496
|
+
},
|
|
497
|
+
presentCall: args => ({ card: 'generic', title: `酒店搜索:${String((args.query as { destination?: string })?.destination ?? '')}`, kind: 'search', rawInput: args.query }),
|
|
498
|
+
presentResult: (_args, value) => {
|
|
499
|
+
const r = value as { hotels?: unknown[]; via?: string; destination?: string; summary?: string }
|
|
500
|
+
const n = Array.isArray(r.hotels) ? r.hotels.length : 0
|
|
501
|
+
return {
|
|
502
|
+
card: 'generic',
|
|
503
|
+
title: `酒店:${r.destination ?? ''} ${n ? `${n} 家(${r.via === 'hbcli-realtime' ? '实时' : '静态包'})` : '无结果'}`,
|
|
504
|
+
content: [{ type: 'text', text: String(r.summary ?? '') }],
|
|
266
505
|
}
|
|
267
|
-
return JSON.parse(JSON.stringify(payload)) as Record<string, unknown>
|
|
268
506
|
},
|
|
269
|
-
presentCall: args => ({ card: 'generic', title: `酒店搜索:${String((args.query as { destination?: string })?.destination ?? '')}`, kind: 'other', rawInput: args.query }),
|
|
270
507
|
}))
|
|
271
508
|
|
|
272
509
|
registerGuarded(defineTool({
|
|
@@ -280,22 +517,18 @@ export function apply(ctx: Context, config: Config): void {
|
|
|
280
517
|
to: { type: 'string', required: true, description: 'IATA, e.g. HKT' },
|
|
281
518
|
},
|
|
282
519
|
output: {
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
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 ?? '') }],
|
|
520
|
+
// loose json(与兄弟工具一致):strict additionalProperties:false 会拒 guard 的
|
|
521
|
+
// 错误兜底字段(ok/summary),校验错掩盖真实错误
|
|
522
|
+
schema: { type: 'json' },
|
|
523
|
+
render: (_args, value) => [{ type: 'text', text: String((value as { evidence?: string }).evidence ?? JSON.stringify(value)) }],
|
|
293
524
|
},
|
|
294
|
-
async execute(args: { from
|
|
295
|
-
|
|
296
|
-
|
|
525
|
+
async execute(args: { from?: string; to?: string; query?: unknown } & Record<string, unknown>, _exec: unknown) {
|
|
526
|
+
// 平铺参数的工具也会被 LLM 包进 query(#12/#13 同款形态),unwrapQuery 兜住
|
|
527
|
+
const q = unwrapQuery<{ from?: string; to?: string }>(args)
|
|
528
|
+
const verdict = await checkConnectivity(String(q.from ?? ''), String(q.to ?? ''))
|
|
529
|
+
return JSON.parse(JSON.stringify({ ok: true, ...verdict })) as Record<string, never>
|
|
297
530
|
},
|
|
298
|
-
presentCall: args => ({ card: 'generic', title: `骨架校验:${args.from}-${args.to}`, kind: '
|
|
531
|
+
presentCall: args => ({ card: 'generic', title: `骨架校验:${args.from}-${args.to}`, kind: 'execute', rawInput: args }),
|
|
299
532
|
}))
|
|
300
533
|
|
|
301
534
|
registerGuarded(defineTool({
|
|
@@ -309,7 +542,7 @@ export function apply(ctx: Context, config: Config): void {
|
|
|
309
542
|
query: {
|
|
310
543
|
type: 'json',
|
|
311
544
|
required: true,
|
|
312
|
-
description: '{ place: "
|
|
545
|
+
description: '{ place: "<城市名>", month?: 8, mode?: "forecast"|"climate", days?: 7 }',
|
|
313
546
|
},
|
|
314
547
|
},
|
|
315
548
|
output: {
|
|
@@ -317,7 +550,7 @@ export function apply(ctx: Context, config: Config): void {
|
|
|
317
550
|
render: (_args, value) => [{ type: 'text', text: String((value as { summary?: string }).summary ?? JSON.stringify(value).slice(0, 600)) }],
|
|
318
551
|
},
|
|
319
552
|
async execute(args: { query: unknown }, _exec: unknown) {
|
|
320
|
-
const q =
|
|
553
|
+
const q = unwrapQuery<{ place?: string; lat?: number; lng?: number; month?: number; mode?: string; days?: number }>(args, 'place')
|
|
321
554
|
const started = Date.now()
|
|
322
555
|
let lat: number | undefined = q.lat, lng: number | undefined = q.lng
|
|
323
556
|
let placeLabel = q.place ?? `${q.lat},${q.lng}`
|
|
@@ -348,7 +581,17 @@ export function apply(ctx: Context, config: Config): void {
|
|
|
348
581
|
latency_ms: Date.now() - started,
|
|
349
582
|
})) as Record<string, never>
|
|
350
583
|
},
|
|
351
|
-
presentCall: args => ({ card: 'generic', title: `天气:${String((args.query as { place?: string })?.place ?? '')}`, kind: '
|
|
584
|
+
presentCall: args => ({ card: 'generic', title: `天气:${String((args.query as { place?: string })?.place ?? '')}`, kind: 'fetch', rawInput: args.query }),
|
|
585
|
+
presentResult: (args, value) => {
|
|
586
|
+
const r = value as { summary?: string }
|
|
587
|
+
const place = String((args.query as { place?: string })?.place ?? '')
|
|
588
|
+
const failed = String(r.summary ?? '').includes('降级') || String(r.summary ?? '').includes('unavailable')
|
|
589
|
+
return {
|
|
590
|
+
card: 'generic',
|
|
591
|
+
title: `天气:${place} ${failed ? '降级' : 'ok'}`,
|
|
592
|
+
content: [{ type: 'text', text: String(r.summary ?? '') }],
|
|
593
|
+
}
|
|
594
|
+
},
|
|
352
595
|
}))
|
|
353
596
|
|
|
354
597
|
registerGuarded(defineTool({
|
|
@@ -374,7 +617,7 @@ export function apply(ctx: Context, config: Config): void {
|
|
|
374
617
|
render: (_args, value) => [{ type: 'text', text: String((value as { summary?: string }).summary ?? JSON.stringify(value).slice(0, 500)) }],
|
|
375
618
|
},
|
|
376
619
|
async execute(args: { query: unknown }, _exec: unknown) {
|
|
377
|
-
const q =
|
|
620
|
+
const q = unwrapQuery<{ callsign: string; airport?: string; timeoutMs?: number }>(args, 'callsign')
|
|
378
621
|
const started = Date.now()
|
|
379
622
|
if (!q.callsign) {
|
|
380
623
|
return JSON.parse(JSON.stringify({ verdict: 'unavailable', evidence: '[校验不可用:无 callsign]', summary: 'callsign 必填' })) as Record<string, never>
|
|
@@ -388,18 +631,85 @@ export function apply(ctx: Context, config: Config): void {
|
|
|
388
631
|
? `${r.callsign} 当前观测列表未见(ADS-B 覆盖有限,不否定该航班存在)\n${r.evidence}`
|
|
389
632
|
: `${r.callsign} OpenSky 不可用:${r.error}\n${r.evidence}`
|
|
390
633
|
return JSON.parse(JSON.stringify({
|
|
634
|
+
ok: true,
|
|
391
635
|
verdict: r.verdict, callsign: r.callsign, airport: r.airport,
|
|
392
636
|
sample_size: r.sampleSize, hits: r.hits, evidence: r.evidence, summary,
|
|
393
637
|
latency_ms: Date.now() - started,
|
|
394
638
|
})) as Record<string, never>
|
|
395
639
|
},
|
|
396
|
-
presentCall: args => ({ card: 'generic', title: `飞行校验:${String((args.query as { callsign?: string })?.callsign ?? '')}`, kind: '
|
|
640
|
+
presentCall: args => ({ card: 'generic', title: `飞行校验:${String((args.query as { callsign?: string })?.callsign ?? '')}`, kind: 'fetch', rawInput: args.query }),
|
|
641
|
+
}))
|
|
642
|
+
|
|
643
|
+
registerGuarded(defineTool({
|
|
644
|
+
name: 'gotry_flyai_search',
|
|
645
|
+
description:
|
|
646
|
+
'Live flight/train search via Fliggy official FlyAI channel (read-only, no key, booking only via jumpUrl by the human). '
|
|
647
|
+
+ 'PRIMARY source for real schedules & prices (RFC user-session-data-rfc). Input: kind=flight|train, from/to city names (Chinese), date YYYY-MM-DD. '
|
|
648
|
+
+ 'Evidence [实时API:flyai@ts]. Cross-validate expensive claims against gotry_session_search when it matters.',
|
|
649
|
+
parameters: {
|
|
650
|
+
query: { type: 'json', required: true, description: '{ kind: "flight"|"train", from: "上海", to: "丽江", date: "2026-10-01" }' },
|
|
651
|
+
},
|
|
652
|
+
output: { schema: { type: 'json' }, render: (_a, v) => [{ type: 'text', text: String((v as { summary?: string }).summary ?? JSON.stringify(v).slice(0, 600)) }] },
|
|
653
|
+
async execute(args: { query: unknown }, _exec: unknown) {
|
|
654
|
+
const q = unwrapQuery<{ kind?: string; from?: string; to?: string; date?: string }>(args, 'from')
|
|
655
|
+
const kind = q.kind === 'train' ? 'train' : 'flight'
|
|
656
|
+
if (!q.from || !q.to || !q.date) {
|
|
657
|
+
return { ok: false, summary: '需要 from/to(中文城市名)与 date(YYYY-MM-DD)' } as const
|
|
658
|
+
}
|
|
659
|
+
const r = await flyaiSearch({ kind, origin: q.from, destination: q.to, depDate: q.date })
|
|
660
|
+
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}`)
|
|
661
|
+
const summary = r.verdict === 'hit'
|
|
662
|
+
? `${q.from}→${q.to} ${q.date} ${kind === 'flight' ? '机票' : '火车票'}(飞猪官方只读)前 ${top.length} 条:\n${top.join('\n')}\n${r.evidence}`
|
|
663
|
+
: `${q.from}→${q.to} ${q.date} 无结果或失败:${r.error ?? 'miss'} ${r.evidence}`
|
|
664
|
+
return JSON.parse(JSON.stringify({ ...r, kind, summary })) as Record<string, never>
|
|
665
|
+
},
|
|
666
|
+
presentCall: args => ({ card: 'generic', title: `官方检索:${String((args.query as { kind?: string })?.kind ?? 'flight')}`, kind: 'fetch', rawInput: args.query }),
|
|
667
|
+
presentResult: (args, value) => {
|
|
668
|
+
const r = value as { ok?: boolean; options?: unknown[] }
|
|
669
|
+
return { card: 'generic', title: `飞猪检索:${r.ok && (r.options?.length ?? 0) > 0 ? `${r.options!.length} 条` : '降级'}`, content: [{ type: 'text', text: String((value as { summary?: string }).summary ?? '') }] }
|
|
670
|
+
},
|
|
671
|
+
}))
|
|
672
|
+
|
|
673
|
+
registerGuarded(defineTool({
|
|
674
|
+
name: 'gotry_session_search',
|
|
675
|
+
description:
|
|
676
|
+
'Cross-validation search on the user\'s OWN logged-in browser session (dedicated persistent profile, ReadGuard = physically read-only: '
|
|
677
|
+
+ 'write requests are aborted at network layer; agent NEVER touches credentials/captcha; on captcha it stops and returns challenged). '
|
|
678
|
+
+ 'Currently ctrip-flight: sniffs the site search API for structured options. Evidence [会话:ctrip-flight@ts]. '
|
|
679
|
+
+ 'verdict needs-login = run scripts/session-login.ts once with the human logging in. Rate-limited (≥30s between same-site calls). '
|
|
680
|
+
+ 'Use to verify/double-check prices from gotry_flyai_search on the same itinerary (split by 直达/中转).',
|
|
681
|
+
parameters: {
|
|
682
|
+
query: { type: 'json', required: true, description: '{ from: "上海", to: "丽江", date: "2026-10-01" }' },
|
|
683
|
+
},
|
|
684
|
+
output: { schema: { type: 'json' }, render: (_a, v) => [{ type: 'text', text: String((v as { summary?: string }).summary ?? JSON.stringify(v).slice(0, 600)) }] },
|
|
685
|
+
async execute(args: { query: unknown }, _exec: unknown) {
|
|
686
|
+
const q = unwrapQuery<{ from?: string; to?: string; date?: string }>(args, 'from')
|
|
687
|
+
if (!q.from || !q.to || !q.date) {
|
|
688
|
+
return { ok: false, summary: '需要 from/to(中文城市名,词表内)与 date(YYYY-MM-DD)' } as const
|
|
689
|
+
}
|
|
690
|
+
const r = await sessionFlightSearch({
|
|
691
|
+
from: q.from, to: q.to, date: q.date,
|
|
692
|
+
// ADR-15 收尾:ReadGuard 审计在生产工具路径同样落盘(此前仅测试传隔离 stateRoot 才有 JSONL)
|
|
693
|
+
auditPath: join(config.stateRoot ?? '.', 'gotry-state', 'session-incidents.jsonl'),
|
|
694
|
+
})
|
|
695
|
+
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}`)
|
|
696
|
+
const summary = r.verdict === 'hit'
|
|
697
|
+
? `${q.from}→${q.to} ${q.date} 会话检索(携程,用户本人登录态)前 ${top.length} 条:\n${top.join('\n')}\n${r.evidence}`
|
|
698
|
+
: `会话检索未取回(${r.verdict}):${r.error ?? ''} ${r.evidence}`
|
|
699
|
+
return JSON.parse(JSON.stringify({ ...r, summary })) as Record<string, never>
|
|
700
|
+
},
|
|
701
|
+
presentCall: args => ({ card: 'generic', title: `会话检索:${String((args.query as { from?: string })?.from ?? '')}`, kind: 'fetch', rawInput: args.query }),
|
|
702
|
+
presentResult: (_args, value) => {
|
|
703
|
+
const r = value as { verdict?: string; options?: unknown[] }
|
|
704
|
+
const label = r.verdict === 'hit' ? `会话 ${r.options!.length} 条` : (r.verdict === 'needs-login' ? '需登录' : r.verdict ?? '降级')
|
|
705
|
+
return { card: 'generic', title: `会话检索:${label}`, content: [{ type: 'text', text: String((value as { summary?: string }).summary ?? '') }] }
|
|
706
|
+
},
|
|
397
707
|
}))
|
|
398
708
|
|
|
399
709
|
registerGuarded(defineTool({
|
|
400
710
|
name: 'gotry_anything_search',
|
|
401
711
|
description:
|
|
402
|
-
'
|
|
712
|
+
'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. ' +
|
|
403
713
|
'Mixed destinations (cities / metropolitan areas / high-level regions) + hotels in one call. ' +
|
|
404
714
|
'Returns candidates with type, name, optional coordinates and hotel-id. ' +
|
|
405
715
|
'Three-valued semantics: hit = ≥1 candidate; miss = 0 candidates (try synonyms or contentType=city/hotel); ' +
|
|
@@ -410,7 +720,7 @@ export function apply(ctx: Context, config: Config): void {
|
|
|
410
720
|
query: {
|
|
411
721
|
type: 'json',
|
|
412
722
|
required: true,
|
|
413
|
-
description: '{ keyword: "
|
|
723
|
+
description: '{ keyword: "<搜索关键词>", contentType?: "city"|"hotel", parentDestinationId?: "?", timeoutMs?: 12000 }',
|
|
414
724
|
},
|
|
415
725
|
},
|
|
416
726
|
output: {
|
|
@@ -418,7 +728,7 @@ export function apply(ctx: Context, config: Config): void {
|
|
|
418
728
|
render: (_args, value) => [{ type: 'text', text: String((value as { summary?: string }).summary ?? JSON.stringify(value).slice(0, 800)) }],
|
|
419
729
|
},
|
|
420
730
|
async execute(args: { query: unknown }, _exec: unknown) {
|
|
421
|
-
const q =
|
|
731
|
+
const q = unwrapQuery<{ keyword: string; contentType?: 'city' | 'hotel'; parentDestinationId?: string | number; timeoutMs?: number }>(args, 'keyword')
|
|
422
732
|
const started = Date.now()
|
|
423
733
|
if (!q.keyword) {
|
|
424
734
|
return JSON.parse(JSON.stringify({ ok: false, verdict: 'error', summary: 'keyword 必填', evidence: '[hbcli-anything@error] empty' })) as Record<string, never>
|
|
@@ -439,7 +749,16 @@ export function apply(ctx: Context, config: Config): void {
|
|
|
439
749
|
latency_ms: Date.now() - started,
|
|
440
750
|
})) as Record<string, never>
|
|
441
751
|
},
|
|
442
|
-
presentCall: args => ({ card: 'generic', title: `Anything search:${String((args.query as { keyword?: string })?.keyword ?? '')}`, kind: '
|
|
752
|
+
presentCall: args => ({ card: 'generic', title: `Anything search:${String((args.query as { keyword?: string })?.keyword ?? '')}`, kind: 'search', rawInput: args.query }),
|
|
753
|
+
presentResult: (_args, value) => {
|
|
754
|
+
const r = value as { hits?: unknown[]; total_candidates?: number; verdict?: string; keyword?: string; summary?: string }
|
|
755
|
+
const n = Array.isArray(r.hits) ? r.hits.length : (r.total_candidates ?? 0)
|
|
756
|
+
return {
|
|
757
|
+
card: 'generic',
|
|
758
|
+
title: `Anything:${r.keyword ?? ''} ${r.verdict === 'hit' ? `${n} hits` : (r.verdict ?? 'no-result')}`,
|
|
759
|
+
content: [{ type: 'text', text: String(r.summary ?? '') }],
|
|
760
|
+
}
|
|
761
|
+
},
|
|
443
762
|
}))
|
|
444
763
|
|
|
445
764
|
registerGuarded(defineTool({
|
|
@@ -462,7 +781,7 @@ export function apply(ctx: Context, config: Config): void {
|
|
|
462
781
|
render: (_args, value) => [{ type: 'text', text: String((value as { content?: string }).content?.slice(0, 800) ?? JSON.stringify(value).slice(0, 800)) }],
|
|
463
782
|
},
|
|
464
783
|
async execute(args: { query: unknown }, _exec: unknown) {
|
|
465
|
-
const q =
|
|
784
|
+
const q = unwrapQuery<{ url?: string; timeoutMs?: number }>(args, 'url')
|
|
466
785
|
const started = Date.now()
|
|
467
786
|
if (!q.url) {
|
|
468
787
|
return JSON.parse(JSON.stringify({ ok: false, summary: 'url 必填', evidence: '[agent-reach:error] empty url' })) as Record<string, never>
|
|
@@ -479,7 +798,7 @@ export function apply(ctx: Context, config: Config): void {
|
|
|
479
798
|
latency_ms: Date.now() - started,
|
|
480
799
|
})) as Record<string, never>
|
|
481
800
|
},
|
|
482
|
-
presentCall: args => ({ card: 'generic', title: `读网页:${String((args.query as { url?: string })?.url ?? '')}`, kind: '
|
|
801
|
+
presentCall: args => ({ card: 'generic', title: `读网页:${String((args.query as { url?: string })?.url ?? '')}`, kind: 'fetch', rawInput: args.query }),
|
|
483
802
|
}))
|
|
484
803
|
|
|
485
804
|
registerGuarded(defineTool({
|
|
@@ -501,7 +820,7 @@ export function apply(ctx: Context, config: Config): void {
|
|
|
501
820
|
render: (_args, value) => [{ type: 'text', text: String((value as { summary?: string }).summary ?? JSON.stringify(value).slice(0, 600)) }],
|
|
502
821
|
},
|
|
503
822
|
async execute(args: { query: unknown }, _exec: unknown) {
|
|
504
|
-
const q =
|
|
823
|
+
const q = unwrapQuery<{ url?: string; lang?: string }>(args, 'url')
|
|
505
824
|
if (!q.url) {
|
|
506
825
|
return JSON.parse(JSON.stringify({ ok: false, summary: 'url 必填' })) as Record<string, never>
|
|
507
826
|
}
|
|
@@ -517,7 +836,7 @@ export function apply(ctx: Context, config: Config): void {
|
|
|
517
836
|
latency_ms: r.latencyMs,
|
|
518
837
|
})) as Record<string, never>
|
|
519
838
|
},
|
|
520
|
-
presentCall: args => ({ card: 'generic', title: `视频字幕:${String((args.query as { url?: string })?.url ?? '')}`, kind: '
|
|
839
|
+
presentCall: args => ({ card: 'generic', title: `视频字幕:${String((args.query as { url?: string })?.url ?? '')}`, kind: 'fetch', rawInput: args.query }),
|
|
521
840
|
}))
|
|
522
841
|
|
|
523
842
|
registerGuarded(defineTool({
|
|
@@ -539,7 +858,7 @@ export function apply(ctx: Context, config: Config): void {
|
|
|
539
858
|
render: (_args, value) => [{ type: 'text', text: String((value as { summary?: string }).summary ?? JSON.stringify(value).slice(0, 600)) }],
|
|
540
859
|
},
|
|
541
860
|
async execute(args: { query: unknown }, _exec: unknown) {
|
|
542
|
-
const q =
|
|
861
|
+
const q = unwrapQuery<{ query?: string; limit?: number }>(args, 'query')
|
|
543
862
|
if (!q.query) {
|
|
544
863
|
return JSON.parse(JSON.stringify({ ok: false, summary: 'query 必填' })) as Record<string, never>
|
|
545
864
|
}
|
|
@@ -555,18 +874,19 @@ export function apply(ctx: Context, config: Config): void {
|
|
|
555
874
|
latency_ms: r.latencyMs,
|
|
556
875
|
})) as Record<string, never>
|
|
557
876
|
},
|
|
558
|
-
presentCall: args => ({ card: 'generic', title: `GitHub 搜索:${String((args.query as { query?: string })?.query ?? '')}`, kind: '
|
|
877
|
+
presentCall: args => ({ card: 'generic', title: `GitHub 搜索:${String((args.query as { query?: string })?.query ?? '')}`, kind: 'search', rawInput: args.query }),
|
|
559
878
|
}))
|
|
560
879
|
|
|
561
880
|
registerGuarded(defineTool({
|
|
562
881
|
name: 'gotry_agent_reach',
|
|
563
882
|
description:
|
|
564
|
-
'Agent Reach — thin wrapper over Panniantong/Agent-Reach upstream registry
|
|
883
|
+
'Agent Reach — the PRIMARY external-data gateway (thin wrapper over Panniantong/Agent-Reach upstream registry, zero channel knowledge here). ' +
|
|
884
|
+
'Prefer this for ANY external/internet fact beyond weather/flights/hotels. ' +
|
|
565
885
|
'Call ANY upstream channel method by reflection: web.read(url) / v2ex.get_hot_topics() / v2ex.search(query) / ' +
|
|
566
886
|
'xueqiu.get_stock_quote(symbol) / xueqiu.search_stock(query) / youtube.transcribe(url) / <channel>.check() ... ' +
|
|
567
887
|
'Unknown channel or method? Just call it — the error returns the upstream inventory (channel list or method signatures) so you can self-correct. ' +
|
|
568
888
|
'Action "status" runs the real `agent-reach doctor` (.venv/bin/agent-reach). ' +
|
|
569
|
-
'Channels needing cookies/setup return the upstream check() guidance verbatim
|
|
889
|
+
'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. ' +
|
|
570
890
|
'Evidence chain: [agent-reach:<channel>.<method>@ts].',
|
|
571
891
|
parameters: {
|
|
572
892
|
query: {
|
|
@@ -580,7 +900,7 @@ export function apply(ctx: Context, config: Config): void {
|
|
|
580
900
|
render: (_args, value) => [{ type: 'text', text: String((value as { summary?: string }).summary ?? JSON.stringify(value).slice(0, 800)) }],
|
|
581
901
|
},
|
|
582
902
|
async execute(args: { query: unknown }, _exec: unknown) {
|
|
583
|
-
const q =
|
|
903
|
+
const q = unwrapQuery<{ action?: string; channel?: string; method?: string; args?: string; timeoutMs?: number }>(args, 'channel')
|
|
584
904
|
const started = Date.now()
|
|
585
905
|
const dir = await ensureStateDir(config.stateRoot)
|
|
586
906
|
|
|
@@ -600,6 +920,24 @@ export function apply(ctx: Context, config: Config): void {
|
|
|
600
920
|
return JSON.parse(JSON.stringify({ ok: false, summary: 'channel 与 method 必填(或 action=status);清单可先随便调一次,inventory 会带回上游渠道/方法表' })) as Record<string, never>
|
|
601
921
|
}
|
|
602
922
|
const r = await reach({ channel: q.channel, method: q.method, args: q.args, timeoutMs: q.timeoutMs })
|
|
923
|
+
// 长结果干净截断:数组按条目边界保留(dsh 工具上限会拦腰断 JSON,模型只能看到半条)
|
|
924
|
+
const dataStr = (v: unknown): unknown => {
|
|
925
|
+
if (Array.isArray(v)) {
|
|
926
|
+
const kept: unknown[] = []
|
|
927
|
+
let budget = 3500
|
|
928
|
+
for (const item of v) {
|
|
929
|
+
const s = JSON.stringify(item)
|
|
930
|
+
if (budget - s.length < 0) break
|
|
931
|
+
budget -= s.length + 1
|
|
932
|
+
kept.push(item)
|
|
933
|
+
}
|
|
934
|
+
if (kept.length < v.length) kept.push(`…(截断:保留 ${kept.length}/${v.length} 条;可用上游方法带 limit 参数取更少)`)
|
|
935
|
+
return kept
|
|
936
|
+
}
|
|
937
|
+
if (typeof v === 'string') return v.length > 4000 ? v.slice(0, 4000) + '…(截断)' : v
|
|
938
|
+
return v
|
|
939
|
+
}
|
|
940
|
+
if (r.ok) r.data = dataStr(r.data)
|
|
603
941
|
await recordLatency(join(dir, 'bridge-latency.jsonl'), Date.now() - started, `agent-reach:${q.channel}.${q.method}:${r.verdict}`).catch(() => {})
|
|
604
942
|
const summary = r.verdict === 'found'
|
|
605
943
|
? `${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)}`
|
|
@@ -615,6 +953,16 @@ export function apply(ctx: Context, config: Config): void {
|
|
|
615
953
|
latency_ms: Date.now() - started,
|
|
616
954
|
})) as Record<string, never>
|
|
617
955
|
},
|
|
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: '
|
|
956
|
+
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: 'fetch', rawInput: args.query }),
|
|
957
|
+
presentResult: (args, value) => {
|
|
958
|
+
const r = value as { verdict?: string; summary?: string }
|
|
959
|
+
const q = (args.query as { channel?: string; method?: string }) ?? {}
|
|
960
|
+
const icon = r.verdict === 'found' ? '✅' : r.verdict === 'needs-setup' ? '🔧' : r.verdict === 'not-installed' ? '📦' : '❌'
|
|
961
|
+
return {
|
|
962
|
+
card: 'generic',
|
|
963
|
+
title: `AgentReach ${icon} ${q.channel ?? ''}.${q.method ?? 'status'} ${r.verdict ?? ''}`,
|
|
964
|
+
content: [{ type: 'text', text: String(r.summary ?? '') }],
|
|
965
|
+
}
|
|
966
|
+
},
|
|
619
967
|
}))
|
|
620
968
|
}
|