@danceiny/gotry 0.0.1-rc.7 → 0.0.1-rc.8
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 +27 -74
- package/bin/gotry-inner.js +50 -1
- package/bin/gotry-stdio-ask.js +59 -0
- package/cordis.gotry-patch.yml +56 -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/hbcli.js +11 -6
- package/dist/capabilities/incident-log.js +2 -1
- package/dist/scripts/agent-reach-tests.js +1 -1
- package/dist/scripts/agent-reach-wrapper-tests.js +1 -1
- package/dist/scripts/hbcli-tests.js +18 -2
- package/dist/scripts/memory-capture-tests.js +77 -0
- package/dist/scripts/memory-metrics.js +38 -0
- package/dist/scripts/nudge-digest.js +93 -0
- package/dist/scripts/probe-poi-tests.js +9 -0
- package/dist/scripts/replay.js +73 -1
- package/dist/scripts/skeleton-check.js +3 -1
- package/dist/scripts/skills-contract-tests.js +85 -0
- package/dist/scripts/smoke.js +201 -3
- package/dist/scripts/time-eval-tests.js +318 -0
- package/dist/src/dsh-llm.js +27 -7
- package/dist/src/index.js +342 -92
- package/dist/src/loop.js +37 -10
- package/dist/src/memory-capture.js +40 -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/time-anchor.js +100 -0
- package/dist/src/tool-packet.js +12 -0
- package/dist/src/travel-slots.js +144 -0
- 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/scripts/skeleton-check.ts +5 -1
- package/ts/src/dsh-llm.ts +27 -7
- package/ts/src/index.ts +292 -77
- package/ts/src/loop.ts +52 -17
- package/ts/src/mock-llm.ts +13 -1
- package/ts/cordis.gotry-patch.yml +0 -36
package/ts/src/index.ts
CHANGED
|
@@ -14,6 +14,8 @@
|
|
|
14
14
|
*/
|
|
15
15
|
|
|
16
16
|
import { join } from 'node:path'
|
|
17
|
+
import { readFile, writeFile } from 'node:fs/promises'
|
|
18
|
+
import { readFileSync } from 'node:fs'
|
|
17
19
|
import type { Context } from '@deepseek-ai/cordis'
|
|
18
20
|
import z from '@deepseek-ai/schemastery'
|
|
19
21
|
import { defineTool } from '@deepseek-ai/dsh-tools'
|
|
@@ -23,6 +25,12 @@ import { checkConnectivity } from '../scripts/skeleton-check.ts'
|
|
|
23
25
|
import { parseCandidate, parseRequest } from './model.ts'
|
|
24
26
|
import { searchHotels as hbcliSearchHotels } from '../capabilities/hbcli.ts'
|
|
25
27
|
import { installProcessGuards, guardToolExecute } from '../capabilities/incident-log.ts'
|
|
28
|
+
import { interpretArgs, type GotryObservation } from './tool-packet.ts'
|
|
29
|
+
import { appendEvent, projectUtility, type MemoryUtilityEvent } from './memory-utility.ts'
|
|
30
|
+
import { pickNudgeWish, type WishPoolEntry } from './wish-pool.ts'
|
|
31
|
+
import { mergeProfile } from './memory-capture.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'
|
|
@@ -49,6 +57,8 @@ export const Config: z<Config> = z.object({
|
|
|
49
57
|
|
|
50
58
|
interface FeasibilityResult {
|
|
51
59
|
answer_md?: string
|
|
60
|
+
recommended?: string | null
|
|
61
|
+
verdicts?: Array<Record<string, unknown>>
|
|
52
62
|
}
|
|
53
63
|
|
|
54
64
|
interface MotivationProfileInput {
|
|
@@ -63,6 +73,34 @@ interface WishPoolEntryInput {
|
|
|
63
73
|
conditions?: Record<string, unknown>
|
|
64
74
|
}
|
|
65
75
|
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* query 包装参数的三形态归一(#12/#13):实现移居 tool-packet.ts(interpretArgs,
|
|
79
|
+
* RFC S1 interpretation 层语义归位),此处按旧名引 handy 别名,调用点零漂移。
|
|
80
|
+
*/
|
|
81
|
+
const unwrapQuery = interpretArgs
|
|
82
|
+
|
|
83
|
+
/**
|
|
84
|
+
* 动机画像 → persona 紧凑 brief(M4 T1 读回路径):空文件返回 ''(首访),
|
|
85
|
+
* persona 据此决定是否访谈。只读,不猜——画像里没有的字段不编。
|
|
86
|
+
*/
|
|
87
|
+
function renderMotivationBrief(stateRoot: string): string {
|
|
88
|
+
try {
|
|
89
|
+
const raw = readFileSync(join(stateRoot, 'gotry-state', 'motivation-profile.json'), 'utf-8')
|
|
90
|
+
const p = JSON.parse(raw) as { weights?: Record<string, number>; evidence?: string[]; hard?: Record<string, unknown>; updated_at?: string }
|
|
91
|
+
const lines: string[] = ['## 用户记忆(跨会话画像;与用户当轮说法冲突时以用户为准,更新经 gotry_motivation_save)']
|
|
92
|
+
const weights = Object.entries(p.weights ?? {})
|
|
93
|
+
if (weights.length) lines.push(`- 动机权重: ${weights.map(([k, v]) => `${k}=${v}`).join(', ')}(证据 ${p.evidence?.length ?? 0} 条)`)
|
|
94
|
+
const hard = Object.entries(p.hard ?? {})
|
|
95
|
+
if (hard.length) lines.push(`- 硬约束: ${hard.map(([k, v]) => `${k}=${String(v)}`).join(', ')}`)
|
|
96
|
+
lines.push(`- 愿望池: 用 gotry_wish_pool_list 按条件召回(0..1),勿直接堆砌`)
|
|
97
|
+
if (p.updated_at) lines.push(`- 更新于: ${p.updated_at}`)
|
|
98
|
+
return weights.length || hard.length ? lines.join('\n') : ''
|
|
99
|
+
} catch {
|
|
100
|
+
return '' // 首访:无画像
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
|
|
66
104
|
type Json = string | number | boolean | null | Json[] | { [k: string]: Json }
|
|
67
105
|
type JsonObject = { [k: string]: Json }
|
|
68
106
|
|
|
@@ -78,6 +116,14 @@ export function apply(ctx: Context, config: Config): void {
|
|
|
78
116
|
const weekdays = ['日', '一', '二', '三', '四', '五', '六']
|
|
79
117
|
return `${ymd} 周${weekdays[d.getDay()]}`
|
|
80
118
|
})
|
|
119
|
+
// 时间锚点卡(time-anchor 层,确定性):相对日期换算的唯一依据——
|
|
120
|
+
// 明天/下周X/下个月中旬/节日都查卡,LLM 不自算(算术只在代码层)。
|
|
121
|
+
sp?.variable?.('time_anchor_card', () => buildTimeAnchor(new Date()).card)
|
|
122
|
+
|
|
123
|
+
// M4 记忆读回(T1 闭环的另一半):motivation-profile 此前只写不读,每个新会话
|
|
124
|
+
// 模型都是盲的、重新访谈——「回访规划时长降 ≥50%」不可能成立。这里把画像
|
|
125
|
+
// 渲染成紧凑 brief 注入 persona;为空 = 首访。与当轮说法冲突时以用户为准。
|
|
126
|
+
sp?.variable?.('motivation_brief', () => renderMotivationBrief(config.stateRoot ?? '.'))
|
|
81
127
|
|
|
82
128
|
// D-NEW 进程护栏(Z3 WASM crash 教训):dsh 0.1.1-rc.1 缺 uncaughtException
|
|
83
129
|
// handler,插件异常穿透即杀进程。我们在 gotry 侧挂护栏:同步 fsync 写事故证据
|
|
@@ -101,7 +147,8 @@ export function apply(ctx: Context, config: Config): void {
|
|
|
101
147
|
'Check travel candidates against the user\'s motivation and hard constraints using the '
|
|
102
148
|
+ 'door-to-door true-cost engine (wake time, arrival state, energy, usable hours, money). '
|
|
103
149
|
+ 'Input is the structured request (motivation weights + hard constraints + window + budget + home hubs) '
|
|
104
|
-
+ 'and the candidate list (services/transfers/stay costs/min days)
|
|
150
|
+
+ 'and the candidate list (services/transfers/stay costs/min days): '
|
|
151
|
+
+ 'structure { request: { motivation weights, hard constraints, window, budget, home hubs }, candidates: [ { id, label, services, transfers, stay, minDays } ] }. '
|
|
105
152
|
+ 'Returns per-candidate verdicts, unsat cores with minimal-modification suggestions, '
|
|
106
153
|
+ 'a wish-pool entry for infeasible aspirations, and a ready-to-show markdown answer.',
|
|
107
154
|
parameters: {
|
|
@@ -129,9 +176,28 @@ export function apply(ctx: Context, config: Config): void {
|
|
|
129
176
|
const result = solveChoiceSegment(spec, req) as Record<string, unknown>
|
|
130
177
|
const dir = await ensureStateDir(config.stateRoot)
|
|
131
178
|
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' }
|
|
179
|
+
return { ok: true, ...result, latency_ms: Date.now() - started, via: 'in-process-unified' }
|
|
180
|
+
},
|
|
181
|
+
presentCall: args => ({ card: 'generic', title: 'GoTry 可行性检查(门到门全成本)', kind: 'execute', rawInput: args.payload }),
|
|
182
|
+
presentResult: (args, value) => {
|
|
183
|
+
// D-4 卡片赎回:结果卡不再是裸 JSON —— 逐候选判定 + 全成本 vs 预算紧凑行 + 人话答案
|
|
184
|
+
const r = value as FeasibilityResult
|
|
185
|
+
const budget = ((args.payload as { request?: { budget_cny?: number } })?.request)?.budget_cny
|
|
186
|
+
const lines = (r.verdicts ?? []).map(v => {
|
|
187
|
+
const tc = (v.true_cost ?? {}) as { money_cny?: number }
|
|
188
|
+
const money = typeof tc.money_cny === 'number'
|
|
189
|
+
? ` ¥${tc.money_cny}/人` + (typeof budget === 'number' ? `(预算 ¥${budget},${tc.money_cny <= budget ? '余' : '超'} ¥${Math.abs(budget - tc.money_cny)})` : '')
|
|
190
|
+
: ''
|
|
191
|
+
return v.feasible
|
|
192
|
+
? `✅ ${String(v.name ?? v.candidate_id)}${money}${v.candidate_id === r.recommended ? ' ← 推荐' : ''}`
|
|
193
|
+
: `❌ ${String(v.name ?? v.candidate_id)} — ${(Array.isArray(v.unsat_core) ? v.unsat_core.join(',') : '不可行')}`
|
|
194
|
+
})
|
|
195
|
+
return {
|
|
196
|
+
card: 'generic',
|
|
197
|
+
title: `可行性:${r.recommended ? `推荐 ${r.recommended}` : '全部不可行'}`,
|
|
198
|
+
content: [{ type: 'text', text: (lines.length ? lines.join('\n') + '\n\n' : '') + String(r.answer_md ?? '').slice(0, 1500) }],
|
|
199
|
+
}
|
|
133
200
|
},
|
|
134
|
-
presentCall: args => ({ card: 'generic', title: 'GoTry 可行性检查(门到门全成本)', kind: 'other', rawInput: args.payload }),
|
|
135
201
|
}))
|
|
136
202
|
|
|
137
203
|
registerGuarded(defineTool({
|
|
@@ -139,8 +205,9 @@ export function apply(ctx: Context, config: Config): void {
|
|
|
139
205
|
description:
|
|
140
206
|
'Persist the traveler\'s motivation profile (the "why depart" contract object). '
|
|
141
207
|
+ 'This is the B2B reuse seam: downstream plugins consume only MotivationProfile + constraints, '
|
|
142
|
-
+ 'never the principal/sponsor distinction.
|
|
143
|
-
+ '
|
|
208
|
+
+ 'never the principal/sponsor distinction. MERGE semantics (T1): call again with just the NEW '
|
|
209
|
+
+ 'facts learned this turn (weights delta optional but MUST bring fresh evidence; evidence = user quotes); '
|
|
210
|
+
+ 'existing history is never deleted. Requires evidence on every call (P0 anti-fabrication rule).',
|
|
144
211
|
parameters: {
|
|
145
212
|
profile: {
|
|
146
213
|
type: 'json',
|
|
@@ -149,30 +216,35 @@ export function apply(ctx: Context, config: Config): void {
|
|
|
149
216
|
},
|
|
150
217
|
},
|
|
151
218
|
output: {
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
additionalProperties: false,
|
|
155
|
-
properties: {
|
|
156
|
-
saved: { type: 'boolean' },
|
|
157
|
-
path: { type: 'string' },
|
|
158
|
-
profile: { type: 'json' },
|
|
159
|
-
},
|
|
160
|
-
},
|
|
219
|
+
// loose json:guard 兜底字段(ok/summary)不被 strict schema 拒(骨架事故同款)
|
|
220
|
+
schema: { type: 'json' },
|
|
161
221
|
render: (_args, value) => [{ type: 'text', text: `动机画像已保存:${String((value as { path?: string }).path ?? '')}` }],
|
|
162
222
|
},
|
|
163
223
|
async execute(args: { profile: unknown }, _exec: unknown) {
|
|
164
|
-
|
|
165
|
-
|
|
224
|
+
// T1 接线:本工具是增量补丁语义(契约 18——模型每轮把对话新事实带进来),
|
|
225
|
+
// 经 mergeProfile 守门合并进既有画像(追加不删史/幂等/权重变更须伴新证据),
|
|
226
|
+
// 不再整档覆盖。首次调用(无档案)= 全量建立。
|
|
227
|
+
const incoming = (args.profile ?? {}) as { weights?: Record<string, number>; evidence?: string[]; hard?: Record<string, unknown> }
|
|
228
|
+
if (!incoming.evidence?.length) {
|
|
166
229
|
throw new Error('refusing to save a motivation profile without evidence (P0 anti-fabrication rule)')
|
|
167
230
|
}
|
|
168
231
|
const dir = await ensureStateDir(config.stateRoot)
|
|
169
232
|
const path = join(dir, 'motivation-profile.json')
|
|
170
|
-
|
|
171
|
-
|
|
233
|
+
let existing: { weights?: Record<string, number>; evidence?: string[]; hard?: Record<string, unknown> } | null = null
|
|
234
|
+
try {
|
|
235
|
+
existing = await readJson<typeof existing>(path, null)
|
|
236
|
+
} catch { /* 首次保存:无档案 */ }
|
|
237
|
+
const merged = mergeProfile(existing, { weights: incoming.weights, evidence: incoming.evidence, hard: incoming.hard })
|
|
238
|
+
if (!merged) {
|
|
239
|
+
// 幂等:补丁与现有画像完全一致,不落盘
|
|
240
|
+
const currentJson = JSON.parse(JSON.stringify({ ...(existing ?? {}), updated_at: new Date().toISOString() })) as JsonObject
|
|
241
|
+
return { ok: true, saved: false, path, profile: currentJson, summary: '无新内容(幂等跳过)' }
|
|
242
|
+
}
|
|
243
|
+
const saved = JSON.parse(JSON.stringify({ ...merged, updated_at: new Date().toISOString() })) as JsonObject
|
|
172
244
|
await writeJson(path, saved)
|
|
173
|
-
return { saved: true, path, profile: saved }
|
|
245
|
+
return { ok: true, saved: true, path, profile: saved, summary: '画像已合并落盘' }
|
|
174
246
|
},
|
|
175
|
-
presentCall: args => ({ card: 'generic', title: '保存动机画像', kind: '
|
|
247
|
+
presentCall: args => ({ card: 'generic', title: '保存动机画像', kind: 'edit', rawInput: args.profile }),
|
|
176
248
|
}))
|
|
177
249
|
|
|
178
250
|
registerGuarded(defineTool({
|
|
@@ -180,62 +252,135 @@ export function apply(ctx: Context, config: Config): void {
|
|
|
180
252
|
description:
|
|
181
253
|
'Add an aspiration to the "next departure" wish pool — the graceful home for infeasible dreams. '
|
|
182
254
|
+ 'An entry carries its fulfilment conditions (days needed, budget, best months) so a future '
|
|
183
|
-
+ '"next departure" nudge can fire when the window matches.
|
|
255
|
+
+ '"next departure" nudge can fire when the window matches. Each entry gets a stable wish_id '
|
|
256
|
+
+ '(the memory-utility sidecar keys on it); muted:true puts a wish dormant (永不删除,只是不再召回). 憧憬不被拒绝。',
|
|
184
257
|
parameters: {
|
|
185
258
|
entry: {
|
|
186
259
|
type: 'json',
|
|
187
260
|
required: true,
|
|
188
|
-
description: '{ name, reason
|
|
261
|
+
description: '{ name, reason?, conditions: { days, budget_cny, best_months }, muted?: boolean }',
|
|
189
262
|
},
|
|
190
263
|
},
|
|
191
264
|
output: {
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
additionalProperties: false,
|
|
195
|
-
properties: {
|
|
196
|
-
added: { type: 'boolean' },
|
|
197
|
-
total: { type: 'integer' },
|
|
198
|
-
path: { type: 'string' },
|
|
199
|
-
},
|
|
200
|
-
},
|
|
265
|
+
// loose json:guard 兜底字段(ok/summary)不被 strict schema 拒(骨架事故同款)
|
|
266
|
+
schema: { type: 'json' },
|
|
201
267
|
render: (_args, value) => {
|
|
202
268
|
const v = value as { total?: number; path?: string }
|
|
203
269
|
return [{ type: 'text', text: `已加入「下一次出发」清单(共 ${v.total ?? '?'} 项):${v.path ?? ''}` }]
|
|
204
270
|
},
|
|
205
271
|
},
|
|
206
272
|
async execute(args: { entry: unknown }, _exec: unknown) {
|
|
207
|
-
const entry = (args.entry ?? {}) as WishPoolEntryInput
|
|
273
|
+
const entry = (args.entry ?? {}) as WishPoolEntryInput & { muted?: boolean }
|
|
208
274
|
if (!entry.name || !entry.conditions) {
|
|
209
275
|
throw new Error('wish pool entry requires name and conditions (fulfilment conditions are the whole point)')
|
|
210
276
|
}
|
|
211
277
|
const dir = await ensureStateDir(config.stateRoot)
|
|
212
278
|
const path = join(dir, 'wish-pool.json')
|
|
213
279
|
const pool = (await readJson(path, [])) as Array<Record<string, unknown>>
|
|
214
|
-
// 同名憧憬幂等更新(刷新理由与成行条件)
|
|
280
|
+
// 同名憧憬幂等更新(刷新理由与成行条件),不重复落盘;wish_id 稳定(存量补签)
|
|
215
281
|
const existing = pool.findIndex(e => e['name'] === entry.name)
|
|
216
282
|
if (existing >= 0) {
|
|
217
|
-
|
|
283
|
+
const prev = pool[existing] ?? {}
|
|
284
|
+
pool[existing] = {
|
|
285
|
+
...prev,
|
|
286
|
+
wish_id: prev['wish_id'] ?? `w${Date.now().toString(36)}`,
|
|
287
|
+
reason: entry.reason ?? prev['reason'],
|
|
288
|
+
conditions: entry.conditions,
|
|
289
|
+
...(entry.muted !== undefined ? { muted: entry.muted } : {}),
|
|
290
|
+
}
|
|
218
291
|
await writeJson(path, pool)
|
|
219
|
-
return { added: false, total: pool.length, path }
|
|
292
|
+
return { ok: true, added: false, wish_id: String(pool[existing]?.['wish_id']), total: pool.length, path }
|
|
220
293
|
}
|
|
221
|
-
|
|
294
|
+
const created = { wish_id: `w${Date.now().toString(36)}`, reason: '', ...entry, added_at: new Date().toISOString() }
|
|
295
|
+
pool.push(created)
|
|
222
296
|
await writeJson(path, pool)
|
|
223
|
-
return { added: true, total: pool.length, path }
|
|
297
|
+
return { ok: true, added: true, wish_id: created.wish_id, total: pool.length, path }
|
|
224
298
|
},
|
|
225
|
-
presentCall: args => ({ card: 'generic', title: '加入「下一次出发」清单', kind: '
|
|
299
|
+
presentCall: args => ({ card: 'generic', title: '加入「下一次出发」清单', kind: 'edit', rawInput: args.entry }),
|
|
300
|
+
}))
|
|
301
|
+
|
|
302
|
+
registerGuarded(defineTool({
|
|
303
|
+
name: 'gotry_wish_pool_list',
|
|
304
|
+
description:
|
|
305
|
+
'Surface AT MOST ONE "next departure" wish whose fulfilment conditions match the user\'s current window '
|
|
306
|
+
+ '(0..1 rule: never more than one nudge per turn, never push when nothing matches — 憧憬不被拒绝,也不被硬推). '
|
|
307
|
+
+ 'Muted wishes never surface. Surfacing records a recalled event in the memory-utility sidecar. '
|
|
308
|
+
+ 'action="confirm-outcome" records the user-confirmed real-world outcome (attribution helpful/harmful/neutral) — '
|
|
309
|
+
+ 'ONLY pass attribution the user explicitly stated; the agent must never self-attribute usefulness.',
|
|
310
|
+
parameters: {
|
|
311
|
+
query: {
|
|
312
|
+
type: 'json',
|
|
313
|
+
required: true,
|
|
314
|
+
description: '{ action?: "recall"|"confirm-outcome", days?: number, budgetCny?: number, month?: number, wishId?: string, attribution?: "helpful"|"harmful"|"neutral", detail?: string }',
|
|
315
|
+
},
|
|
316
|
+
},
|
|
317
|
+
output: {
|
|
318
|
+
schema: { type: 'json' },
|
|
319
|
+
render: (_args, value) => [{ type: 'text', text: String((value as { summary?: string }).summary ?? '') }],
|
|
320
|
+
},
|
|
321
|
+
async execute(args: { query: unknown }, _exec: unknown) {
|
|
322
|
+
const q = unwrapQuery<{ action?: string; days?: number; budgetCny?: number; month?: number; wishId?: string; attribution?: 'helpful' | 'harmful' | 'neutral'; detail?: string }>(args, 'action')
|
|
323
|
+
const dir = await ensureStateDir(config.stateRoot)
|
|
324
|
+
const pool = (await readJson(join(dir, 'wish-pool.json'), [])) as Array<Record<string, unknown>>
|
|
325
|
+
const sidecarPath = join(dir, 'memory-utility.jsonl')
|
|
326
|
+
const loadSidecar = async (): Promise<MemoryUtilityEvent[]> => {
|
|
327
|
+
try {
|
|
328
|
+
const raw = await readFile(sidecarPath, 'utf-8')
|
|
329
|
+
return raw.split('\n').filter(Boolean).map(l => JSON.parse(l) as MemoryUtilityEvent)
|
|
330
|
+
} catch { return [] } // fail-open:sidecar 缺失/损坏不阻塞召回
|
|
331
|
+
}
|
|
332
|
+
const saveSidecar = async (events: MemoryUtilityEvent[]) => {
|
|
333
|
+
await writeFile(sidecarPath, events.map(e => JSON.stringify(e)).join('\n') + '\n', 'utf-8')
|
|
334
|
+
}
|
|
335
|
+
const now = new Date().toISOString()
|
|
336
|
+
if (q.action === 'confirm-outcome') {
|
|
337
|
+
if (!q.wishId || !q.attribution) {
|
|
338
|
+
return { ok: false, summary: 'confirm-outcome 需要 wishId + attribution(helpful|harmful|neutral)' } as never
|
|
339
|
+
}
|
|
340
|
+
const events = await loadSidecar()
|
|
341
|
+
const { events: next, appended } = appendEvent(events, {
|
|
342
|
+
wish_id: q.wishId, kind: 'verified_outcome', ts: now,
|
|
343
|
+
ctx: 'gotry_wish_pool_list.confirm', detail: q.detail, attribution: q.attribution,
|
|
344
|
+
})
|
|
345
|
+
if (appended) await saveSidecar(next)
|
|
346
|
+
return { ok: true, recorded: appended, wish_id: q.wishId, status: q.attribution } as never
|
|
347
|
+
}
|
|
348
|
+
// recall:0..1 条件匹配(判定归 wish-pool 纯函数),muted 永不召回,无命中不硬推
|
|
349
|
+
const candidates = pool.filter(e => !e['muted'] && typeof e['wish_id'] === 'string')
|
|
350
|
+
const month = q.month ?? new Date().getMonth() + 1
|
|
351
|
+
const match = pickNudgeWish(candidates as WishPoolEntry[], { days: q.days, budgetCny: q.budgetCny, month })
|
|
352
|
+
if (!match) {
|
|
353
|
+
return { ok: true, suggestion: null, summary: `无可成行的憧憬匹配当前窗口(${candidates.length} 条在册,0..1 纪律:不硬推)` } as never
|
|
354
|
+
}
|
|
355
|
+
const events = await loadSidecar()
|
|
356
|
+
const { events: next, appended } = appendEvent(events, {
|
|
357
|
+
wish_id: match.wishId, kind: 'recalled', ts: now, ctx: 'gotry_wish_pool_list.recall',
|
|
358
|
+
})
|
|
359
|
+
if (appended) await saveSidecar(next)
|
|
360
|
+
const utility = projectUtility(next)[match.wishId]
|
|
361
|
+
return {
|
|
362
|
+
ok: true,
|
|
363
|
+
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 },
|
|
364
|
+
utility: { status: utility?.status ?? 'unknown', recalled: utility?.recalled ?? 1 },
|
|
365
|
+
summary: `「下一次出发」候选(0..1):${String(match.entry['name'])}——成行条件 ${JSON.stringify(match.entry['conditions'])},本次窗口命中 ${match.score}/3 项(${match.hits.join('+')});效用状态 ${utility?.status ?? 'unknown'}`,
|
|
366
|
+
} as never
|
|
367
|
+
},
|
|
368
|
+
presentCall: args => ({ card: 'generic', title: '「下一次出发」召回', kind: 'search', rawInput: args.query }),
|
|
226
369
|
}))
|
|
227
370
|
|
|
228
371
|
registerGuarded(defineTool({
|
|
229
372
|
name: 'gotry_hotel_search',
|
|
230
373
|
description:
|
|
231
374
|
'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.
|
|
375
|
+
+ 'Input: destination city name + optional dates/occupancy. Dates accept verbatim natural expressions (下周五 / 8.20 / 下周五+3) — '
|
|
376
|
+
+ 'the code layer resolves them against the time anchor; unresolved expressions degrade to an undated search with an explicit '
|
|
377
|
+
+ 'date_notes entry instead of guessing. Output: hotel list with evidence chain ([realtime-API:hbcli] + fetch timestamp, '
|
|
233
378
|
+ 'or [static-pack:estimate]) per the L4 invariant.',
|
|
234
379
|
parameters: {
|
|
235
380
|
query: {
|
|
236
381
|
type: 'json',
|
|
237
382
|
required: true,
|
|
238
|
-
description: '{ destination: "
|
|
383
|
+
description: '{ destination: "<目的地城市>", checkIn?: "YYYY-MM-DD 或自然表达(下周五/8.20)", checkOut?: 同上, occupancy?: { adults: 2 } }',
|
|
239
384
|
},
|
|
240
385
|
},
|
|
241
386
|
output: {
|
|
@@ -243,12 +388,26 @@ export function apply(ctx: Context, config: Config): void {
|
|
|
243
388
|
render: (_args, value) => [{ type: 'text', text: String((value as { summary?: string }).summary ?? JSON.stringify(value).slice(0, 400)) }],
|
|
244
389
|
},
|
|
245
390
|
async execute(args: { query: unknown }, _exec: unknown) {
|
|
246
|
-
const q =
|
|
391
|
+
const q = unwrapQuery<{ destination?: string; checkIn?: string; checkOut?: string; adults?: number }>(args, 'destination')
|
|
247
392
|
if (!q.destination) throw new Error('gotry_hotel_search requires destination')
|
|
248
393
|
const started = Date.now()
|
|
249
394
|
const fallbackPath = join(import.meta.dirname, '..', '..', 'data', 'hotels_2026.json')
|
|
395
|
+
// D-10 切片 B:日期槽位接受逐字自然表达(下周五/8.20/+N),代码层换算(slot-spec);
|
|
396
|
+
// unresolved 不猜——降级为无日期搜索并显式记 note,由模型向用户追问
|
|
397
|
+
const anchor = buildTimeAnchor(new Date())
|
|
398
|
+
const dateNotes: string[] = []
|
|
399
|
+
const resolveDate = (expr?: string): string | undefined => {
|
|
400
|
+
if (!expr) return undefined
|
|
401
|
+
const r = resolveSlotDate(expr, anchor)
|
|
402
|
+
if (!r.date) {
|
|
403
|
+
dateNotes.push(`日期未解析:${r.raw}——请向用户确认具体日期`)
|
|
404
|
+
return undefined
|
|
405
|
+
}
|
|
406
|
+
if (r.raw !== r.date) dateNotes.push(`slot-resolved: ${r.raw} → ${r.date}`)
|
|
407
|
+
return r.date
|
|
408
|
+
}
|
|
250
409
|
const resp = await hbcliSearchHotels(
|
|
251
|
-
{ destination: q.destination, checkIn: q.checkIn, checkOut: q.checkOut, adults: q.adults },
|
|
410
|
+
{ destination: q.destination, checkIn: resolveDate(q.checkIn), checkOut: resolveDate(q.checkOut), adults: q.adults },
|
|
252
411
|
{ hbcliBin: config.hbcliBin, timeoutMs: config.timeoutMs, fallbackPath },
|
|
253
412
|
)
|
|
254
413
|
const dir = await ensureStateDir(config.stateRoot)
|
|
@@ -256,6 +415,7 @@ export function apply(ctx: Context, config: Config): void {
|
|
|
256
415
|
const evidence = isLive ? resp.evidence : '[静态包:估算]'
|
|
257
416
|
await recordLatency(join(dir, 'bridge-latency.jsonl'), Date.now() - started, `hotel_search:${resp.via}`).catch(() => {})
|
|
258
417
|
const payload = {
|
|
418
|
+
ok: true,
|
|
259
419
|
hotels: resp.hotels ?? null,
|
|
260
420
|
evidence,
|
|
261
421
|
destination: q.destination,
|
|
@@ -263,10 +423,20 @@ export function apply(ctx: Context, config: Config): void {
|
|
|
263
423
|
latency_ms: Date.now() - started,
|
|
264
424
|
summary: resp.summary,
|
|
265
425
|
error: resp.error,
|
|
426
|
+
...(dateNotes.length ? { date_notes: dateNotes } : {}),
|
|
427
|
+
} as never
|
|
428
|
+
return JSON.parse(JSON.stringify(payload)) as never
|
|
429
|
+
},
|
|
430
|
+
presentCall: args => ({ card: 'generic', title: `酒店搜索:${String((args.query as { destination?: string })?.destination ?? '')}`, kind: 'search', rawInput: args.query }),
|
|
431
|
+
presentResult: (_args, value) => {
|
|
432
|
+
const r = value as { hotels?: unknown[]; via?: string; destination?: string; summary?: string }
|
|
433
|
+
const n = Array.isArray(r.hotels) ? r.hotels.length : 0
|
|
434
|
+
return {
|
|
435
|
+
card: 'generic',
|
|
436
|
+
title: `酒店:${r.destination ?? ''} ${n ? `${n} 家(${r.via === 'hbcli-realtime' ? '实时' : '静态包'})` : '无结果'}`,
|
|
437
|
+
content: [{ type: 'text', text: String(r.summary ?? '') }],
|
|
266
438
|
}
|
|
267
|
-
return JSON.parse(JSON.stringify(payload)) as Record<string, unknown>
|
|
268
439
|
},
|
|
269
|
-
presentCall: args => ({ card: 'generic', title: `酒店搜索:${String((args.query as { destination?: string })?.destination ?? '')}`, kind: 'other', rawInput: args.query }),
|
|
270
440
|
}))
|
|
271
441
|
|
|
272
442
|
registerGuarded(defineTool({
|
|
@@ -280,22 +450,18 @@ export function apply(ctx: Context, config: Config): void {
|
|
|
280
450
|
to: { type: 'string', required: true, description: 'IATA, e.g. HKT' },
|
|
281
451
|
},
|
|
282
452
|
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 ?? '') }],
|
|
453
|
+
// loose json(与兄弟工具一致):strict additionalProperties:false 会拒 guard 的
|
|
454
|
+
// 错误兜底字段(ok/summary),校验错掩盖真实错误
|
|
455
|
+
schema: { type: 'json' },
|
|
456
|
+
render: (_args, value) => [{ type: 'text', text: String((value as { evidence?: string }).evidence ?? JSON.stringify(value)) }],
|
|
293
457
|
},
|
|
294
|
-
async execute(args: { from
|
|
295
|
-
|
|
296
|
-
|
|
458
|
+
async execute(args: { from?: string; to?: string; query?: unknown } & Record<string, unknown>, _exec: unknown) {
|
|
459
|
+
// 平铺参数的工具也会被 LLM 包进 query(#12/#13 同款形态),unwrapQuery 兜住
|
|
460
|
+
const q = unwrapQuery<{ from?: string; to?: string }>(args)
|
|
461
|
+
const verdict = await checkConnectivity(String(q.from ?? ''), String(q.to ?? ''))
|
|
462
|
+
return JSON.parse(JSON.stringify({ ok: true, ...verdict })) as Record<string, never>
|
|
297
463
|
},
|
|
298
|
-
presentCall: args => ({ card: 'generic', title: `骨架校验:${args.from}-${args.to}`, kind: '
|
|
464
|
+
presentCall: args => ({ card: 'generic', title: `骨架校验:${args.from}-${args.to}`, kind: 'execute', rawInput: args }),
|
|
299
465
|
}))
|
|
300
466
|
|
|
301
467
|
registerGuarded(defineTool({
|
|
@@ -309,7 +475,7 @@ export function apply(ctx: Context, config: Config): void {
|
|
|
309
475
|
query: {
|
|
310
476
|
type: 'json',
|
|
311
477
|
required: true,
|
|
312
|
-
description: '{ place: "
|
|
478
|
+
description: '{ place: "<城市名>", month?: 8, mode?: "forecast"|"climate", days?: 7 }',
|
|
313
479
|
},
|
|
314
480
|
},
|
|
315
481
|
output: {
|
|
@@ -317,7 +483,7 @@ export function apply(ctx: Context, config: Config): void {
|
|
|
317
483
|
render: (_args, value) => [{ type: 'text', text: String((value as { summary?: string }).summary ?? JSON.stringify(value).slice(0, 600)) }],
|
|
318
484
|
},
|
|
319
485
|
async execute(args: { query: unknown }, _exec: unknown) {
|
|
320
|
-
const q =
|
|
486
|
+
const q = unwrapQuery<{ place?: string; lat?: number; lng?: number; month?: number; mode?: string; days?: number }>(args, 'place')
|
|
321
487
|
const started = Date.now()
|
|
322
488
|
let lat: number | undefined = q.lat, lng: number | undefined = q.lng
|
|
323
489
|
let placeLabel = q.place ?? `${q.lat},${q.lng}`
|
|
@@ -348,7 +514,17 @@ export function apply(ctx: Context, config: Config): void {
|
|
|
348
514
|
latency_ms: Date.now() - started,
|
|
349
515
|
})) as Record<string, never>
|
|
350
516
|
},
|
|
351
|
-
presentCall: args => ({ card: 'generic', title: `天气:${String((args.query as { place?: string })?.place ?? '')}`, kind: '
|
|
517
|
+
presentCall: args => ({ card: 'generic', title: `天气:${String((args.query as { place?: string })?.place ?? '')}`, kind: 'fetch', rawInput: args.query }),
|
|
518
|
+
presentResult: (args, value) => {
|
|
519
|
+
const r = value as { summary?: string }
|
|
520
|
+
const place = String((args.query as { place?: string })?.place ?? '')
|
|
521
|
+
const failed = String(r.summary ?? '').includes('降级') || String(r.summary ?? '').includes('unavailable')
|
|
522
|
+
return {
|
|
523
|
+
card: 'generic',
|
|
524
|
+
title: `天气:${place} ${failed ? '降级' : 'ok'}`,
|
|
525
|
+
content: [{ type: 'text', text: String(r.summary ?? '') }],
|
|
526
|
+
}
|
|
527
|
+
},
|
|
352
528
|
}))
|
|
353
529
|
|
|
354
530
|
registerGuarded(defineTool({
|
|
@@ -374,7 +550,7 @@ export function apply(ctx: Context, config: Config): void {
|
|
|
374
550
|
render: (_args, value) => [{ type: 'text', text: String((value as { summary?: string }).summary ?? JSON.stringify(value).slice(0, 500)) }],
|
|
375
551
|
},
|
|
376
552
|
async execute(args: { query: unknown }, _exec: unknown) {
|
|
377
|
-
const q =
|
|
553
|
+
const q = unwrapQuery<{ callsign: string; airport?: string; timeoutMs?: number }>(args, 'callsign')
|
|
378
554
|
const started = Date.now()
|
|
379
555
|
if (!q.callsign) {
|
|
380
556
|
return JSON.parse(JSON.stringify({ verdict: 'unavailable', evidence: '[校验不可用:无 callsign]', summary: 'callsign 必填' })) as Record<string, never>
|
|
@@ -388,18 +564,19 @@ export function apply(ctx: Context, config: Config): void {
|
|
|
388
564
|
? `${r.callsign} 当前观测列表未见(ADS-B 覆盖有限,不否定该航班存在)\n${r.evidence}`
|
|
389
565
|
: `${r.callsign} OpenSky 不可用:${r.error}\n${r.evidence}`
|
|
390
566
|
return JSON.parse(JSON.stringify({
|
|
567
|
+
ok: true,
|
|
391
568
|
verdict: r.verdict, callsign: r.callsign, airport: r.airport,
|
|
392
569
|
sample_size: r.sampleSize, hits: r.hits, evidence: r.evidence, summary,
|
|
393
570
|
latency_ms: Date.now() - started,
|
|
394
571
|
})) as Record<string, never>
|
|
395
572
|
},
|
|
396
|
-
presentCall: args => ({ card: 'generic', title: `飞行校验:${String((args.query as { callsign?: string })?.callsign ?? '')}`, kind: '
|
|
573
|
+
presentCall: args => ({ card: 'generic', title: `飞行校验:${String((args.query as { callsign?: string })?.callsign ?? '')}`, kind: 'fetch', rawInput: args.query }),
|
|
397
574
|
}))
|
|
398
575
|
|
|
399
576
|
registerGuarded(defineTool({
|
|
400
577
|
name: 'gotry_anything_search',
|
|
401
578
|
description:
|
|
402
|
-
'
|
|
579
|
+
'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
580
|
'Mixed destinations (cities / metropolitan areas / high-level regions) + hotels in one call. ' +
|
|
404
581
|
'Returns candidates with type, name, optional coordinates and hotel-id. ' +
|
|
405
582
|
'Three-valued semantics: hit = ≥1 candidate; miss = 0 candidates (try synonyms or contentType=city/hotel); ' +
|
|
@@ -410,7 +587,7 @@ export function apply(ctx: Context, config: Config): void {
|
|
|
410
587
|
query: {
|
|
411
588
|
type: 'json',
|
|
412
589
|
required: true,
|
|
413
|
-
description: '{ keyword: "
|
|
590
|
+
description: '{ keyword: "<搜索关键词>", contentType?: "city"|"hotel", parentDestinationId?: "?", timeoutMs?: 12000 }',
|
|
414
591
|
},
|
|
415
592
|
},
|
|
416
593
|
output: {
|
|
@@ -418,7 +595,7 @@ export function apply(ctx: Context, config: Config): void {
|
|
|
418
595
|
render: (_args, value) => [{ type: 'text', text: String((value as { summary?: string }).summary ?? JSON.stringify(value).slice(0, 800)) }],
|
|
419
596
|
},
|
|
420
597
|
async execute(args: { query: unknown }, _exec: unknown) {
|
|
421
|
-
const q =
|
|
598
|
+
const q = unwrapQuery<{ keyword: string; contentType?: 'city' | 'hotel'; parentDestinationId?: string | number; timeoutMs?: number }>(args, 'keyword')
|
|
422
599
|
const started = Date.now()
|
|
423
600
|
if (!q.keyword) {
|
|
424
601
|
return JSON.parse(JSON.stringify({ ok: false, verdict: 'error', summary: 'keyword 必填', evidence: '[hbcli-anything@error] empty' })) as Record<string, never>
|
|
@@ -439,7 +616,16 @@ export function apply(ctx: Context, config: Config): void {
|
|
|
439
616
|
latency_ms: Date.now() - started,
|
|
440
617
|
})) as Record<string, never>
|
|
441
618
|
},
|
|
442
|
-
presentCall: args => ({ card: 'generic', title: `Anything search:${String((args.query as { keyword?: string })?.keyword ?? '')}`, kind: '
|
|
619
|
+
presentCall: args => ({ card: 'generic', title: `Anything search:${String((args.query as { keyword?: string })?.keyword ?? '')}`, kind: 'search', rawInput: args.query }),
|
|
620
|
+
presentResult: (_args, value) => {
|
|
621
|
+
const r = value as { hits?: unknown[]; total_candidates?: number; verdict?: string; keyword?: string; summary?: string }
|
|
622
|
+
const n = Array.isArray(r.hits) ? r.hits.length : (r.total_candidates ?? 0)
|
|
623
|
+
return {
|
|
624
|
+
card: 'generic',
|
|
625
|
+
title: `Anything:${r.keyword ?? ''} ${r.verdict === 'hit' ? `${n} hits` : (r.verdict ?? 'no-result')}`,
|
|
626
|
+
content: [{ type: 'text', text: String(r.summary ?? '') }],
|
|
627
|
+
}
|
|
628
|
+
},
|
|
443
629
|
}))
|
|
444
630
|
|
|
445
631
|
registerGuarded(defineTool({
|
|
@@ -462,7 +648,7 @@ export function apply(ctx: Context, config: Config): void {
|
|
|
462
648
|
render: (_args, value) => [{ type: 'text', text: String((value as { content?: string }).content?.slice(0, 800) ?? JSON.stringify(value).slice(0, 800)) }],
|
|
463
649
|
},
|
|
464
650
|
async execute(args: { query: unknown }, _exec: unknown) {
|
|
465
|
-
const q =
|
|
651
|
+
const q = unwrapQuery<{ url?: string; timeoutMs?: number }>(args, 'url')
|
|
466
652
|
const started = Date.now()
|
|
467
653
|
if (!q.url) {
|
|
468
654
|
return JSON.parse(JSON.stringify({ ok: false, summary: 'url 必填', evidence: '[agent-reach:error] empty url' })) as Record<string, never>
|
|
@@ -479,7 +665,7 @@ export function apply(ctx: Context, config: Config): void {
|
|
|
479
665
|
latency_ms: Date.now() - started,
|
|
480
666
|
})) as Record<string, never>
|
|
481
667
|
},
|
|
482
|
-
presentCall: args => ({ card: 'generic', title: `读网页:${String((args.query as { url?: string })?.url ?? '')}`, kind: '
|
|
668
|
+
presentCall: args => ({ card: 'generic', title: `读网页:${String((args.query as { url?: string })?.url ?? '')}`, kind: 'fetch', rawInput: args.query }),
|
|
483
669
|
}))
|
|
484
670
|
|
|
485
671
|
registerGuarded(defineTool({
|
|
@@ -501,7 +687,7 @@ export function apply(ctx: Context, config: Config): void {
|
|
|
501
687
|
render: (_args, value) => [{ type: 'text', text: String((value as { summary?: string }).summary ?? JSON.stringify(value).slice(0, 600)) }],
|
|
502
688
|
},
|
|
503
689
|
async execute(args: { query: unknown }, _exec: unknown) {
|
|
504
|
-
const q =
|
|
690
|
+
const q = unwrapQuery<{ url?: string; lang?: string }>(args, 'url')
|
|
505
691
|
if (!q.url) {
|
|
506
692
|
return JSON.parse(JSON.stringify({ ok: false, summary: 'url 必填' })) as Record<string, never>
|
|
507
693
|
}
|
|
@@ -517,7 +703,7 @@ export function apply(ctx: Context, config: Config): void {
|
|
|
517
703
|
latency_ms: r.latencyMs,
|
|
518
704
|
})) as Record<string, never>
|
|
519
705
|
},
|
|
520
|
-
presentCall: args => ({ card: 'generic', title: `视频字幕:${String((args.query as { url?: string })?.url ?? '')}`, kind: '
|
|
706
|
+
presentCall: args => ({ card: 'generic', title: `视频字幕:${String((args.query as { url?: string })?.url ?? '')}`, kind: 'fetch', rawInput: args.query }),
|
|
521
707
|
}))
|
|
522
708
|
|
|
523
709
|
registerGuarded(defineTool({
|
|
@@ -539,7 +725,7 @@ export function apply(ctx: Context, config: Config): void {
|
|
|
539
725
|
render: (_args, value) => [{ type: 'text', text: String((value as { summary?: string }).summary ?? JSON.stringify(value).slice(0, 600)) }],
|
|
540
726
|
},
|
|
541
727
|
async execute(args: { query: unknown }, _exec: unknown) {
|
|
542
|
-
const q =
|
|
728
|
+
const q = unwrapQuery<{ query?: string; limit?: number }>(args, 'query')
|
|
543
729
|
if (!q.query) {
|
|
544
730
|
return JSON.parse(JSON.stringify({ ok: false, summary: 'query 必填' })) as Record<string, never>
|
|
545
731
|
}
|
|
@@ -555,18 +741,19 @@ export function apply(ctx: Context, config: Config): void {
|
|
|
555
741
|
latency_ms: r.latencyMs,
|
|
556
742
|
})) as Record<string, never>
|
|
557
743
|
},
|
|
558
|
-
presentCall: args => ({ card: 'generic', title: `GitHub 搜索:${String((args.query as { query?: string })?.query ?? '')}`, kind: '
|
|
744
|
+
presentCall: args => ({ card: 'generic', title: `GitHub 搜索:${String((args.query as { query?: string })?.query ?? '')}`, kind: 'search', rawInput: args.query }),
|
|
559
745
|
}))
|
|
560
746
|
|
|
561
747
|
registerGuarded(defineTool({
|
|
562
748
|
name: 'gotry_agent_reach',
|
|
563
749
|
description:
|
|
564
|
-
'Agent Reach — thin wrapper over Panniantong/Agent-Reach upstream registry
|
|
750
|
+
'Agent Reach — the PRIMARY external-data gateway (thin wrapper over Panniantong/Agent-Reach upstream registry, zero channel knowledge here). ' +
|
|
751
|
+
'Prefer this for ANY external/internet fact beyond weather/flights/hotels. ' +
|
|
565
752
|
'Call ANY upstream channel method by reflection: web.read(url) / v2ex.get_hot_topics() / v2ex.search(query) / ' +
|
|
566
753
|
'xueqiu.get_stock_quote(symbol) / xueqiu.search_stock(query) / youtube.transcribe(url) / <channel>.check() ... ' +
|
|
567
754
|
'Unknown channel or method? Just call it — the error returns the upstream inventory (channel list or method signatures) so you can self-correct. ' +
|
|
568
755
|
'Action "status" runs the real `agent-reach doctor` (.venv/bin/agent-reach). ' +
|
|
569
|
-
'Channels needing cookies/setup return the upstream check() guidance verbatim
|
|
756
|
+
'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
757
|
'Evidence chain: [agent-reach:<channel>.<method>@ts].',
|
|
571
758
|
parameters: {
|
|
572
759
|
query: {
|
|
@@ -580,7 +767,7 @@ export function apply(ctx: Context, config: Config): void {
|
|
|
580
767
|
render: (_args, value) => [{ type: 'text', text: String((value as { summary?: string }).summary ?? JSON.stringify(value).slice(0, 800)) }],
|
|
581
768
|
},
|
|
582
769
|
async execute(args: { query: unknown }, _exec: unknown) {
|
|
583
|
-
const q =
|
|
770
|
+
const q = unwrapQuery<{ action?: string; channel?: string; method?: string; args?: string; timeoutMs?: number }>(args, 'channel')
|
|
584
771
|
const started = Date.now()
|
|
585
772
|
const dir = await ensureStateDir(config.stateRoot)
|
|
586
773
|
|
|
@@ -600,6 +787,24 @@ export function apply(ctx: Context, config: Config): void {
|
|
|
600
787
|
return JSON.parse(JSON.stringify({ ok: false, summary: 'channel 与 method 必填(或 action=status);清单可先随便调一次,inventory 会带回上游渠道/方法表' })) as Record<string, never>
|
|
601
788
|
}
|
|
602
789
|
const r = await reach({ channel: q.channel, method: q.method, args: q.args, timeoutMs: q.timeoutMs })
|
|
790
|
+
// 长结果干净截断:数组按条目边界保留(dsh 工具上限会拦腰断 JSON,模型只能看到半条)
|
|
791
|
+
const dataStr = (v: unknown): unknown => {
|
|
792
|
+
if (Array.isArray(v)) {
|
|
793
|
+
const kept: unknown[] = []
|
|
794
|
+
let budget = 3500
|
|
795
|
+
for (const item of v) {
|
|
796
|
+
const s = JSON.stringify(item)
|
|
797
|
+
if (budget - s.length < 0) break
|
|
798
|
+
budget -= s.length + 1
|
|
799
|
+
kept.push(item)
|
|
800
|
+
}
|
|
801
|
+
if (kept.length < v.length) kept.push(`…(截断:保留 ${kept.length}/${v.length} 条;可用上游方法带 limit 参数取更少)`)
|
|
802
|
+
return kept
|
|
803
|
+
}
|
|
804
|
+
if (typeof v === 'string') return v.length > 4000 ? v.slice(0, 4000) + '…(截断)' : v
|
|
805
|
+
return v
|
|
806
|
+
}
|
|
807
|
+
if (r.ok) r.data = dataStr(r.data)
|
|
603
808
|
await recordLatency(join(dir, 'bridge-latency.jsonl'), Date.now() - started, `agent-reach:${q.channel}.${q.method}:${r.verdict}`).catch(() => {})
|
|
604
809
|
const summary = r.verdict === 'found'
|
|
605
810
|
? `${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 +820,16 @@ export function apply(ctx: Context, config: Config): void {
|
|
|
615
820
|
latency_ms: Date.now() - started,
|
|
616
821
|
})) as Record<string, never>
|
|
617
822
|
},
|
|
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: '
|
|
823
|
+
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 }),
|
|
824
|
+
presentResult: (args, value) => {
|
|
825
|
+
const r = value as { verdict?: string; summary?: string }
|
|
826
|
+
const q = (args.query as { channel?: string; method?: string }) ?? {}
|
|
827
|
+
const icon = r.verdict === 'found' ? '✅' : r.verdict === 'needs-setup' ? '🔧' : r.verdict === 'not-installed' ? '📦' : '❌'
|
|
828
|
+
return {
|
|
829
|
+
card: 'generic',
|
|
830
|
+
title: `AgentReach ${icon} ${q.channel ?? ''}.${q.method ?? 'status'} ${r.verdict ?? ''}`,
|
|
831
|
+
content: [{ type: 'text', text: String(r.summary ?? '') }],
|
|
832
|
+
}
|
|
833
|
+
},
|
|
619
834
|
}))
|
|
620
835
|
}
|