@danceiny/gotry 0.0.1-rc.13 → 0.0.1-rc.15

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/dist/src/index.js CHANGED
@@ -23,6 +23,7 @@ import { flyaiSearch } from '../capabilities/flyai.js';
23
23
  import { sessionFlightSearch } from '../capabilities/session-search.js';
24
24
  import { sessionLogin } from '../capabilities/session-login.js';
25
25
  import { createConsentGate, approvalFromContext } from '../capabilities/session-consent.js';
26
+ import { listArtifacts, readArtifact } from '../capabilities/artifacts.js';
26
27
  export const name = 'gotry-tools';
27
28
  export const inject = [
28
29
  'tools',
@@ -585,10 +586,14 @@ export function apply(ctx, config) {
585
586
  }),
586
587
  presentResult: (_args, value)=>{
587
588
  const r = value;
588
- const n = Array.isArray(r.hotels) ? r.hotels.length : 0;
589
+ const h = r.hotels;
590
+ const liveCount = Array.isArray(h) ? h.length : 0;
591
+ const stays = !Array.isArray(h) && h && typeof h === 'object' ? h.stays : undefined;
592
+ const staticCount = Array.isArray(stays) ? stays.length : 0;
593
+ const tag = r.via === 'hbcli-realtime' ? liveCount ? `实时 ${liveCount} 家` : '实时' : staticCount ? `静态包 ${staticCount} 块` : liveCount ? `${liveCount} 家` : '无结果';
589
594
  return {
590
595
  card: 'generic',
591
- title: `酒店:${r.destination ?? ''} ${n ? `${n} 家(${r.via === 'hbcli-realtime' ? '实时' : '静态包'})` : '无结果'}`,
596
+ title: `酒店:${r.destination ?? ''} ${tag}`,
592
597
  content: [
593
598
  {
594
599
  type: 'text',
@@ -875,7 +880,8 @@ export function apply(ctx, config) {
875
880
  depDate: q.date
876
881
  });
877
882
  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}`);
878
- const summary = r.verdict === 'hit' ? `${q.from}→${q.to} ${q.date} ${kind === 'flight' ? '机票' : '火车票'}(飞猪官方只读)前 ${top.length} 条:\n${top.join('\n')}\n${r.evidence}` : `${q.from}→${q.to} ${q.date} 无结果或失败:${r.error ?? 'miss'} ${r.evidence}`;
883
+ const label = kind === 'flight' ? '机票' : '火车票';
884
+ const summary = r.verdict === 'hit' ? `${q.from}→${q.to} ${q.date} ${label}(飞猪官方只读)前 ${top.length} 条:\n${top.join('\n')}\n${r.evidence}` : r.verdict === 'miss' ? `${q.from}→${q.to} ${q.date} ${label}官方通道正常返回 0 条(常见原因:航线未开放/当日售罄)。${r.evidence}` : `${q.from}→${q.to} ${q.date} ${label}检索失败(可能限流/网络):${r.error ?? ''} ${r.evidence}`;
879
885
  return JSON.parse(JSON.stringify({
880
886
  ...r,
881
887
  kind,
@@ -1352,6 +1358,138 @@ export function apply(ctx, config) {
1352
1358
  };
1353
1359
  }
1354
1360
  }));
1361
+ registerGuarded(defineTool({
1362
+ name: 'gotry_artifacts_list',
1363
+ description: 'List GoTry artifacts — deep-planning deliverables (async runs from the state ledger) plus agent-written markdown files ' + 'in the working directory (trip plans etc.). READ-ONLY discovery. ' + 'Use when the user asks to see/open/revisit a previously generated artifact ' + '(「看看刚才生成的行程」「上次的规划在哪」「打开那个 md」) — list first, then read with gotry_artifacts_read.',
1364
+ parameters: {
1365
+ query: {
1366
+ type: 'json',
1367
+ required: true,
1368
+ description: '{ limit?: 20 }'
1369
+ }
1370
+ },
1371
+ output: {
1372
+ schema: {
1373
+ type: 'json'
1374
+ },
1375
+ render: (_args, value)=>[
1376
+ {
1377
+ type: 'text',
1378
+ text: String(value.summary ?? JSON.stringify(value).slice(0, 600))
1379
+ }
1380
+ ]
1381
+ },
1382
+ async execute (args, _exec) {
1383
+ const q = unwrapQuery(args, 'limit');
1384
+ const r = await listArtifacts({
1385
+ stateRoot: config.stateRoot ?? '.',
1386
+ limit: q.limit
1387
+ });
1388
+ const lines = r.artifacts.map((a)=>`- [${a.source}] ${a.title}${a.status ? `(${a.status})` : ''} — ${a.path}${a.updated ? ` @ ${a.updated.slice(0, 16).replace('T', ' ')}` : ''}`);
1389
+ const summary = r.artifacts.length ? `在册产物 ${r.artifacts.length}/${r.total} 项${r.truncated ? '(截断,可加 limit)' : ''}:\n${lines.join('\n')}` : '无在册产物(异步深度规划交付与工作目录 md 文件都会出现在这里)';
1390
+ return JSON.parse(JSON.stringify({
1391
+ ok: true,
1392
+ artifacts: r.artifacts,
1393
+ total: r.total,
1394
+ truncated: r.truncated,
1395
+ summary
1396
+ }));
1397
+ },
1398
+ presentCall: ()=>({
1399
+ card: 'generic',
1400
+ title: '列出产物',
1401
+ kind: 'search'
1402
+ }),
1403
+ presentResult: (_args, value)=>{
1404
+ const r = value;
1405
+ return {
1406
+ card: 'generic',
1407
+ title: `产物:${r.total ?? 0} 项在册`,
1408
+ content: [
1409
+ {
1410
+ type: 'text',
1411
+ text: String(r.summary ?? '')
1412
+ }
1413
+ ]
1414
+ };
1415
+ }
1416
+ }));
1417
+ registerGuarded(defineTool({
1418
+ name: 'gotry_artifacts_read',
1419
+ description: 'Read one GoTry artifact as a line-numbered file view rendered directly in the chat UI. ' + 'Input: the path from gotry_artifacts_list, or a bare async ticket id (e.g. dp-xxxx). ' + 'Optional offset (1-based) / limit window for paging large files. ' + 'READ-ONLY; text artifacts only (md/txt/json/jsonl/csv/log/yaml).',
1420
+ parameters: {
1421
+ query: {
1422
+ type: 'json',
1423
+ required: true,
1424
+ description: '{ path: "<list 返回的路径或工单 id>", offset?: 1, limit?: 400 }'
1425
+ }
1426
+ },
1427
+ output: {
1428
+ schema: {
1429
+ type: 'json'
1430
+ },
1431
+ render: (_args, value)=>[
1432
+ {
1433
+ type: 'text',
1434
+ text: String(value.content ?? JSON.stringify(value).slice(0, 600))
1435
+ }
1436
+ ]
1437
+ },
1438
+ async execute (args, _exec) {
1439
+ const q = unwrapQuery(args, 'path');
1440
+ if (!q.path) return JSON.parse(JSON.stringify({
1441
+ ok: false,
1442
+ error: 'path 必填(来自 gotry_artifacts_list)'
1443
+ }));
1444
+ const r = await readArtifact({
1445
+ stateRoot: config.stateRoot ?? '.',
1446
+ path: q.path,
1447
+ offset: q.offset,
1448
+ limit: q.limit
1449
+ });
1450
+ if (!r.ok) return JSON.parse(JSON.stringify(r));
1451
+ return JSON.parse(JSON.stringify({
1452
+ ...r,
1453
+ summary: `${r.path}(${r.totalLines} 行)第 ${r.offset}-${r.offset + r.lines.length - 1} 行${r.windowed ? `(共 ${r.totalLines} 行,可翻页)` : ''}`
1454
+ }));
1455
+ },
1456
+ presentCall: (args)=>({
1457
+ card: 'generic',
1458
+ title: `读产物:${String(args.query?.path ?? '')}`,
1459
+ kind: 'read',
1460
+ rawInput: args.query
1461
+ }),
1462
+ presentResult: (_args, value)=>{
1463
+ const r = value;
1464
+ if (!r.ok) {
1465
+ return {
1466
+ card: 'generic',
1467
+ title: '读产物失败',
1468
+ content: [
1469
+ {
1470
+ type: 'text',
1471
+ text: String(r.error ?? '')
1472
+ }
1473
+ ]
1474
+ };
1475
+ }
1476
+ return {
1477
+ card: 'read',
1478
+ title: r.path?.split('/').pop() ?? r.path ?? '',
1479
+ path: r.path ?? '',
1480
+ offset: r.offset ?? 1,
1481
+ lines: r.lines ?? [],
1482
+ totalLines: r.totalLines ?? 0,
1483
+ lang: r.lang,
1484
+ content: [
1485
+ {
1486
+ type: 'text',
1487
+ text: r.content ?? ''
1488
+ }
1489
+ ]
1490
+ };
1491
+ }
1492
+ }));
1355
1493
  }
1356
1494
 
1357
1495
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@danceiny/gotry",
3
- "version": "0.0.1-rc.13",
3
+ "version": "0.0.1-rc.15",
4
4
  "description": "GoTry — 从出发到下一次出发的 AI 旅行 Agent(dsh 插件)。npm 包入口 + vendored dsh runtime + 5 行 README 安装路径。",
5
5
  "type": "module",
6
6
  "main": "ts/src/index.ts",
@@ -20,6 +20,7 @@
20
20
  "ts/src/dsh-llm.ts",
21
21
  "ts/src/mock-llm.ts",
22
22
  "ts/src/state-ledger.ts",
23
+ "ts/src/booking-saga.ts",
23
24
  "ts/src/tool-packet.ts",
24
25
  "ts/src/memory-capture.ts",
25
26
  "ts/src/memory-utility.ts",
@@ -47,9 +48,11 @@
47
48
  "ts/package.json",
48
49
  "cordis.gotry-patch.yml",
49
50
  "README.md",
51
+ "README.zh-CN.md",
50
52
  "LICENSE",
51
53
  "ts/capabilities/session-consent.ts",
52
- "ts/capabilities/session-login.ts"
54
+ "ts/capabilities/session-login.ts",
55
+ "ts/capabilities/artifacts.ts"
53
56
  ],
54
57
  "engines": {
55
58
  "node": ">=22.0.0"
@@ -0,0 +1,235 @@
1
+ /**
2
+ * 产物面(issue #25):agent 生成的文件不再只是「落在本地文件系统里的一个文件名」。
3
+ *
4
+ * 只读能力层,两个纯函数入口(dsh 工具 gotry_artifacts_list / gotry_artifacts_read 的实现):
5
+ * - listArtifacts: 产物发现。权威源 = 账本 workflow_runs(ADR-15;无账本的旧 root 回退
6
+ * 扫描 gotry-state/async/*.deliverable.md 文件视图),外加 dsh 工作目录顶层 *.md
7
+ * (agent 写出的行程/规划文件正落在这里——issue 截图里的 trip-2027-*.md 即此类)。
8
+ * - readArtifact: 产物阅读。行窗口(offset/limit)+ 原始行号,输出 dsh read 卡所需的
9
+ * 全部字段({number,text}[] / totalLines / lang),UI 侧渲染为行号文件视图。
10
+ *
11
+ * 纪律:本层只读(不写任何文件;WriteGate 红线不涉及);读取范围白名单 =
12
+ * stateRoot 根 + dsh 工作目录(排除 node_modules/.git),扩展名白名单 =
13
+ * 文本类(md/txt/json/jsonl/csv/log/yaml/yml)——本工具是「产物查看」,不是通用文件浏览器。
14
+ */
15
+
16
+ import { readdir, readFile, stat } from 'node:fs/promises'
17
+ import { existsSync } from 'node:fs'
18
+ import { isAbsolute, join, resolve, sep } from 'node:path'
19
+
20
+ import { openLedgerIfExists } from '../src/state-ledger.ts'
21
+
22
+ export interface ArtifactEntry {
23
+ source: 'async-run' | 'cwd-file'
24
+ id: string
25
+ title: string
26
+ path: string
27
+ status?: string
28
+ updated?: string
29
+ bytes?: number
30
+ }
31
+
32
+ export interface ArtifactReadView {
33
+ ok: true
34
+ path: string
35
+ offset: number
36
+ lines: Array<{ number: number; text: string }>
37
+ totalLines: number
38
+ lang?: string
39
+ content: string
40
+ windowed: boolean
41
+ }
42
+
43
+ const MAX_LIST = 50
44
+ const MAX_WINDOW = 400
45
+ const MAX_BYTES = 2 * 1024 * 1024
46
+ const TEXT_EXT_LANG: Record<string, string> = {
47
+ md: 'markdown', txt: 'text', json: 'json', jsonl: 'json', csv: 'csv', log: 'text', yaml: 'yaml', yml: 'yaml',
48
+ }
49
+ const DIR_DENY = ['node_modules', '.git']
50
+
51
+ function rootOf(stateRoot: string): string {
52
+ return stateRoot === '.' ? process.cwd() : resolve(stateRoot)
53
+ }
54
+
55
+ function underRoot(p: string, root: string): boolean {
56
+ const r = resolve(root)
57
+ return p === r || p.startsWith(r + sep)
58
+ }
59
+
60
+ function hasDeniedSegment(p: string): boolean {
61
+ return p.split(sep).some(seg => DIR_DENY.includes(seg))
62
+ }
63
+
64
+ function asyncDeliverablePath(root: string, id: string): string {
65
+ return join(root, 'gotry-state', 'async', `${id}.deliverable.md`)
66
+ }
67
+
68
+ /** 账本 workflow_runs 是权威(listWorkflowRuns 只读 SELECT 直查,不为一个视图改 ledger 类)。 */
69
+ function listRunsFromLedger(root: string, tenant: string, limit: number): ArtifactEntry[] {
70
+ const ledger = openLedgerIfExists(root, tenant)
71
+ if (!ledger) return []
72
+ const rows = ledger.db
73
+ .prepare('SELECT id, goal, status, deliverable, updated FROM workflow_runs WHERE tenant_id = ? ORDER BY updated DESC LIMIT ?')
74
+ .all(ledger.tenant, limit) as Array<{ id: string; goal: string; status: string; deliverable: string | null; updated: string }>
75
+ return rows.map(r => {
76
+ const file = asyncDeliverablePath(root, r.id)
77
+ return {
78
+ source: 'async-run' as const,
79
+ id: r.id,
80
+ title: r.goal,
81
+ path: file,
82
+ status: r.status,
83
+ updated: r.updated,
84
+ bytes: r.deliverable?.length,
85
+ }
86
+ })
87
+ }
88
+
89
+ /** 无账本旧 root 的兼容视图:直接扫 async 目录的 deliverable 文件(只读,与清扫合同同一目录)。 */
90
+ async function listDeliverableFiles(root: string, limit: number): Promise<ArtifactEntry[]> {
91
+ const dir = join(root, 'gotry-state', 'async')
92
+ let names: string[] = []
93
+ try {
94
+ names = (await readdir(dir)).filter(n => n.endsWith('.deliverable.md'))
95
+ } catch {
96
+ return []
97
+ }
98
+ const entries: ArtifactEntry[] = []
99
+ for (const n of names.slice(0, limit * 2)) {
100
+ const p = join(dir, n)
101
+ const st = await stat(p).catch(() => null)
102
+ if (!st?.isFile()) continue
103
+ entries.push({
104
+ source: 'async-run',
105
+ id: n.replace(/\.deliverable\.md$/, ''),
106
+ title: n.replace(/\.deliverable\.md$/, ''),
107
+ path: p,
108
+ status: existsSync(p.replace(/\.deliverable\.md$/, '.json')) ? 'pending-view' : 'legacy',
109
+ updated: new Date(st.mtimeMs).toISOString(),
110
+ bytes: st.size,
111
+ })
112
+ }
113
+ return entries.sort((a, b) => String(b.updated).localeCompare(String(a.updated)))
114
+ }
115
+
116
+ /** dsh 工作目录顶层 *.md(agent 写出的行程规划等);非递归,排除 dotfiles。 */
117
+ async function listCwdMarkdown(cwd: string, limit: number): Promise<ArtifactEntry[]> {
118
+ let dirents
119
+ try {
120
+ dirents = await readdir(cwd, { withFileTypes: true })
121
+ } catch {
122
+ return []
123
+ }
124
+ const entries: ArtifactEntry[] = []
125
+ for (const d of dirents) {
126
+ if (!d.isFile() || !/\.md$/i.test(d.name) || d.name.startsWith('.')) continue
127
+ const p = join(cwd, d.name)
128
+ const st = await stat(p).catch(() => null)
129
+ if (!st) continue
130
+ entries.push({
131
+ source: 'cwd-file',
132
+ id: d.name,
133
+ title: d.name.replace(/\.md$/i, ''),
134
+ path: p,
135
+ updated: new Date(st.mtimeMs).toISOString(),
136
+ bytes: st.size,
137
+ })
138
+ }
139
+ return entries.sort((a, b) => String(b.updated).localeCompare(String(a.updated))).slice(0, limit)
140
+ }
141
+
142
+ export async function listArtifacts(opts: {
143
+ stateRoot: string
144
+ cwd?: string
145
+ limit?: number
146
+ }): Promise<{ artifacts: ArtifactEntry[]; total: number; truncated: boolean; roots: string[] }> {
147
+ const limit = Math.max(1, Math.min(opts.limit ?? 20, MAX_LIST))
148
+ const root = rootOf(opts.stateRoot)
149
+ const cwd = opts.cwd ? resolve(opts.cwd) : process.cwd()
150
+
151
+ const seenPath = new Set<string>()
152
+ const merged: ArtifactEntry[] = []
153
+ for (const e of [...listRunsFromLedger(root, 'local', limit), ...(await listDeliverableFiles(root, limit)), ...(await listCwdMarkdown(cwd, limit))]) {
154
+ if (seenPath.has(e.path)) continue
155
+ seenPath.add(e.path)
156
+ merged.push(e)
157
+ }
158
+ merged.sort((a, b) => String(b.updated ?? '').localeCompare(String(a.updated ?? '')))
159
+
160
+ const total = merged.length
161
+ return { artifacts: merged.slice(0, limit), total, truncated: total > limit, roots: [root, cwd] }
162
+ }
163
+
164
+ /**
165
+ * 读一个产物。path 三形态:
166
+ * 1. 裸工单 id(无 / 无 .)→ 账本 workflow_runs.deliverable(权威),文件视图缺失也能读;
167
+ * 2. list 返回的绝对/相对路径 → 限定在 stateRoot 根或 dsh 工作目录内(排除 node_modules/.git);
168
+ * 3. 相对文件名 → 先按 dsh 工作目录顶层,再按 gotry-state/async/ 找。
169
+ * 窗口:offset(1 起)/limit(≤400 行);超窗返回 windowed:true,UI 用 read 卡渲染行号视图。
170
+ */
171
+ export async function readArtifact(opts: {
172
+ stateRoot: string
173
+ cwd?: string
174
+ path: string
175
+ offset?: number
176
+ limit?: number
177
+ }): Promise<ArtifactReadView | { ok: false; error: string; hint?: string }> {
178
+ const root = rootOf(opts.stateRoot)
179
+ const cwd = opts.cwd ? resolve(opts.cwd) : process.cwd()
180
+ const raw = String(opts.path ?? '').trim()
181
+ if (!raw) return { ok: false, error: 'path 必填(来自 gotry_artifacts_list 的 path,或异步工单 id)' }
182
+
183
+ let text: string | null = null
184
+ let filePath = ''
185
+
186
+ // 1) 裸工单 id:账本权威读(文件视图缺失不挡阅读)
187
+ if (!raw.includes('/') && !raw.includes('\\') && !raw.includes('.')) {
188
+ const ledger = openLedgerIfExists(root, 'local')
189
+ const run = ledger?.db
190
+ .prepare('SELECT id, goal, status, deliverable FROM workflow_runs WHERE id = ? AND tenant_id = ?')
191
+ .get(raw, ledger!.tenant) as { deliverable: string | null } | undefined
192
+ if (run?.deliverable) {
193
+ text = run.deliverable
194
+ filePath = asyncDeliverablePath(root, raw)
195
+ } else {
196
+ const p = asyncDeliverablePath(root, raw)
197
+ if (existsSync(p)) { filePath = p; text = await readFile(p, 'utf-8') }
198
+ }
199
+ if (text === null) return { ok: false, error: `工单 ${raw} 无 deliverable(未交付或不存在)`, hint: '先 gotry_artifacts_list 看在册产物' }
200
+ }
201
+
202
+ // 2) 路径形态:目录白名单 + 扩展名白名单
203
+ if (text === null) {
204
+ const candidates = isAbsolute(raw) ? [resolve(raw)] : [resolve(cwd, raw), resolve(root, raw), resolve(root, 'gotry-state', 'async', raw)]
205
+ const allowed = candidates.find(p => (underRoot(p, cwd) || underRoot(p, root)) && !hasDeniedSegment(p))
206
+ if (!allowed) {
207
+ return { ok: false, error: `路径越界:${raw}`, hint: `只读 ${root} 与 dsh 工作目录内的文本产物` }
208
+ }
209
+ const ext = allowed.slice(allowed.lastIndexOf('.') + 1).toLowerCase()
210
+ if (!TEXT_EXT_LANG[ext]) {
211
+ return { ok: false, error: `不支持的文件类型 .${ext}`, hint: `白名单:${Object.keys(TEXT_EXT_LANG).join('/')}` }
212
+ }
213
+ const st = await stat(allowed).catch(() => null)
214
+ if (!st?.isFile()) return { ok: false, error: `文件不存在:${raw}`, hint: '先 gotry_artifacts_list 看在册产物' }
215
+ if (st.size > MAX_BYTES) return { ok: false, error: `文件过大(${st.size} bytes > ${MAX_BYTES})` }
216
+ filePath = allowed
217
+ text = await readFile(allowed, 'utf-8')
218
+ }
219
+
220
+ const allLines = text.split('\n')
221
+ const offset = Math.max(1, Math.min(opts.offset ?? 1, allLines.length))
222
+ const limit = Math.max(1, Math.min(opts.limit ?? MAX_WINDOW, MAX_WINDOW))
223
+ const slice = allLines.slice(offset - 1, offset - 1 + limit)
224
+ const ext = filePath.slice(filePath.lastIndexOf('.') + 1).toLowerCase()
225
+ return {
226
+ ok: true,
227
+ path: filePath,
228
+ offset,
229
+ lines: slice.map((t, i) => ({ number: offset + i, text: t })),
230
+ totalLines: allLines.length,
231
+ lang: TEXT_EXT_LANG[ext] ?? 'text',
232
+ content: slice.join('\n'),
233
+ windowed: allLines.length > offset - 1 + slice.length,
234
+ }
235
+ }
@@ -13,9 +13,11 @@
13
13
  */
14
14
 
15
15
  import { spawn } from 'node:child_process'
16
+ import { homedir } from 'node:os'
17
+ import { join } from 'node:path'
16
18
 
17
19
  export interface HbcliCallOptions {
18
- /** hbcli 二进制路径(默认 'hbcli',依赖 PATH) */
20
+ /** hbcli 二进制路径(默认 'hbcli',依赖 PATH;~/.local/bin 等已知安装位自动回退) */
19
21
  hbcliBin?: string
20
22
  /** 超时(ms) */
21
23
  timeoutMs?: number
@@ -43,23 +45,29 @@ export interface HbcliCallResult {
43
45
  error?: string
44
46
  }
45
47
 
46
- /** 通用 hbcli JSON 调用封装:失败不抛,而是返回降级结果 */
47
- export async function callHbcliJson(
48
+ /**
49
+ * hbcli 二进制候选路径(gotry setup 按官方脚本装到 ~/.local/bin/hbcli,
50
+ * symlink 指向 ~/.staicli/current/hbcli——当 PATH 不含 ~/.local/bin 时裸名
51
+ * spawn 仍 ENOENT,按已知安装位回退)。仅对默认名 'hbcli' 扩展;显式自定义
52
+ * 名(如测试注入的不存在路径)不扩展,保持配置即所用的可测性。
53
+ */
54
+ export function hbcliBinCandidates(bin: string, homeDir: string = homedir()): string[] {
55
+ if (bin !== 'hbcli') return [bin]
56
+ return [bin, join(homeDir, '.local/bin/hbcli'), join(homeDir, '.staicli/current/hbcli')]
57
+ }
58
+
59
+ /** 单个候选的一次 spawn 封装:失败不抛,返回降级结果(spawnError 标记 ENOENT 类失败供上层换候选) */
60
+ function attemptHbcli(
61
+ bin: string,
48
62
  args: string[],
49
- opts: HbcliCallOptions = {},
50
- ): Promise<HbcliCallResult> {
63
+ opts: Required<Pick<HbcliCallOptions, 'timeoutMs' | 'env'>> & { envVars: Record<string, string> },
64
+ ): Promise<HbcliCallResult & { spawnError?: boolean }> {
51
65
  const started = Date.now()
52
- const bin = opts.hbcliBin ?? 'hbcli'
53
- const timeoutMs = opts.timeoutMs ?? 15_000
54
- const env = opts.env ?? 'uat'
55
- const envVars: Record<string, string> = { HOTELBYTE_ENV: env }
56
- if (opts.token) envVars['HOTELBYTE_TOKEN'] = opts.token
57
-
58
66
  return new Promise((resolve) => {
59
67
  let stdout = ''
60
68
  let stderr = ''
61
69
  let settled = false
62
- const child = spawn(bin, args, { env: { ...process.env, ...envVars } })
70
+ const child = spawn(bin, args, { env: { ...process.env, ...opts.envVars } })
63
71
  const timer = setTimeout(() => {
64
72
  if (!settled) {
65
73
  settled = true
@@ -67,10 +75,10 @@ export async function callHbcliJson(
67
75
  resolve({
68
76
  via: 'hbcli-error', exitCode: -1, result: null,
69
77
  evidence: `[实时API:hbcli@timeout@${new Date().toISOString()}]`,
70
- latencyMs: Date.now() - started, error: `timeout after ${timeoutMs}ms`,
78
+ latencyMs: Date.now() - started, error: `timeout after ${opts.timeoutMs}ms`,
71
79
  })
72
80
  }
73
- }, timeoutMs)
81
+ }, opts.timeoutMs)
74
82
  child.stdout.on('data', (d: Buffer) => { stdout += d.toString() })
75
83
  child.stderr.on('data', (d: Buffer) => { stderr += d.toString() })
76
84
  child.on('close', (code) => {
@@ -105,16 +113,35 @@ export async function callHbcliJson(
105
113
  if (settled) return
106
114
  settled = true
107
115
  clearTimeout(timer)
108
- // ENOENT (二进制不存在) 等也走降级路径
116
+ // ENOENT (二进制不存在) 等也走降级路径;spawnError 供上层按候选路径重试
109
117
  resolve({
110
118
  via: 'hbcli-error', exitCode: -1, result: null,
111
119
  evidence: `[实时API:hbcli@spawn_error@${new Date().toISOString()}]`,
112
- latencyMs: Date.now() - started, error: (e as Error).message,
120
+ latencyMs: Date.now() - started, error: (e as Error).message, spawnError: true,
113
121
  })
114
122
  })
115
123
  })
116
124
  }
117
125
 
126
+ /** 通用 hbcli JSON 调用封装:失败不抛,而是返回降级结果 */
127
+ export async function callHbcliJson(
128
+ args: string[],
129
+ opts: HbcliCallOptions = {},
130
+ ): Promise<HbcliCallResult> {
131
+ const env = opts.env ?? 'uat'
132
+ const envVars: Record<string, string> = { HOTELBYTE_ENV: env }
133
+ if (opts.token) envVars['HOTELBYTE_TOKEN'] = opts.token
134
+ const callOpts = { timeoutMs: opts.timeoutMs ?? 15_000, env, envVars }
135
+ const candidates = hbcliBinCandidates(opts.hbcliBin ?? 'hbcli')
136
+ let last: HbcliCallResult & { spawnError?: boolean } | undefined
137
+ for (const bin of candidates) {
138
+ last = await attemptHbcli(bin, args, callOpts)
139
+ // spawn 级失败(ENOENT 等)且还有候选 → 换下一个已知安装位;其余失败(退码/超时)无重试意义
140
+ if (!(last.spawnError && candidates.indexOf(bin) < candidates.length - 1)) return last
141
+ }
142
+ return last!
143
+ }
144
+
118
145
  /** 高层语义化封装:酒店列表查询(down-tier to 静态包 + 证据链标注) */
119
146
  export async function searchHotels(
120
147
  query: { destination: string; checkIn?: string; checkOut?: string; adults?: number },
@@ -129,6 +156,11 @@ export async function searchHotels(
129
156
  if (live.via === 'hbcli-realtime') {
130
157
  return { ...live, hotels: live.result, summary: `${query.destination}:hbcli 实时返回${query.checkIn || query.checkOut ? '(日期不传上游 list,以当前窗口房价返回)' : ''}` }
131
158
  }
159
+ // 降级原因人话化(issue #24):hbcli 未安装时按 gotry setup 指引(npm 安装期已
160
+ // 自动跑过官方脚本;PATH 未含 ~/.local/bin 时上方候选路径也已兜住),
161
+ // 裸 "spawn hbcli ENOENT" 读起来像工具坏了——实际静态包降级是设计行为
162
+ const rawReason = live.error ?? live.via
163
+ const reason = /ENOENT/i.test(rawReason) ? '未安装 hbcli(可选实时源;npx gotry setup 可按官方脚本安装)' : rawReason
132
164
  // 降级:读静态包,按目的地过滤命中的住宿块(issue #24)——整包倾倒会把无关场景
133
165
  // (深圳/普吉/曼谷/云南/大理混装)灌给模型且不指明哪块相关;包内无该目的地时明示
134
166
  // 「无数据」而不是伪装成可用结果。
@@ -144,17 +176,17 @@ export async function searchHotels(
144
176
  return {
145
177
  ...live,
146
178
  hotels: { stays: matched },
147
- summary: `${query.destination}:hbcli 不可用(${live.error ?? live.via}),降级到静态包,命中 ${matched.length} 个住宿块`,
179
+ summary: `${query.destination}:hbcli 实时源不可用(${reason}),已降级到静态包(公开渠道估算,非实时),命中 ${matched.length} 个住宿块`,
148
180
  }
149
181
  }
150
182
  return {
151
183
  ...live,
152
184
  hotels: null,
153
- summary: `${query.destination}:hbcli 不可用(${live.error ?? live.via}),且静态包无「${query.destination}」住宿数据(静态包仅覆盖内置场景)`,
185
+ summary: `${query.destination}:hbcli 实时源不可用(${reason}),且静态包无「${query.destination}」住宿数据(静态包仅覆盖内置场景)`,
154
186
  }
155
187
  } catch { /* 静态包读不到也优雅降级 */ }
156
188
  }
157
- return { ...live, summary: `${query.destination}:hbcli 不可用且无静态包(仅返回错误)` }
189
+ return { ...live, summary: `${query.destination}:hbcli 实时源不可用(${reason})且无静态包(仅返回错误)` }
158
190
  }
159
191
 
160
192
  /** 高层语义化封装:目的地列表(无数据依赖,通常 hbcli dest 命令可独立调通) */