@danceiny/gotry 0.0.1-rc.10

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (130) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +267 -0
  3. package/bin/gotry-inner.js +214 -0
  4. package/bin/gotry-stdio-ask.js +59 -0
  5. package/bin/gotry.js +28 -0
  6. package/cordis.gotry-patch.yml +86 -0
  7. package/data/flights_2026.json +218 -0
  8. package/data/golden_erhai.json +92 -0
  9. package/data/golden_trip_2026.json +75 -0
  10. package/data/hotels_2026.json +151 -0
  11. package/data/openflights-skeleton.json +895 -0
  12. package/data/time-slot-eval.json +384 -0
  13. package/data/yunnan-pack.json +202 -0
  14. package/dist/capabilities/agent-reach-bridge.py +90 -0
  15. package/dist/capabilities/agent-reach-deep.js +172 -0
  16. package/dist/capabilities/agent-reach.js +204 -0
  17. package/dist/capabilities/anything.js +139 -0
  18. package/dist/capabilities/flyai.js +131 -0
  19. package/dist/capabilities/hbcli.js +143 -0
  20. package/dist/capabilities/incident-log.js +100 -0
  21. package/dist/capabilities/opensky.js +74 -0
  22. package/dist/capabilities/session/action-cache.js +120 -0
  23. package/dist/capabilities/session/adapters/ctrip-flight.js +83 -0
  24. package/dist/capabilities/session/adapters/meituan-local.js +73 -0
  25. package/dist/capabilities/session/extract.js +40 -0
  26. package/dist/capabilities/session/read-guard.js +99 -0
  27. package/dist/capabilities/session/transport.js +101 -0
  28. package/dist/capabilities/session-search.js +93 -0
  29. package/dist/capabilities/weather.js +180 -0
  30. package/dist/scripts/action-cache-tests.js +107 -0
  31. package/dist/scripts/agent-reach-deep-tests.js +54 -0
  32. package/dist/scripts/agent-reach-tests.js +38 -0
  33. package/dist/scripts/agent-reach-wrapper-tests.js +79 -0
  34. package/dist/scripts/anything-tests.js +95 -0
  35. package/dist/scripts/async-collect.js +37 -0
  36. package/dist/scripts/companion-tests.js +112 -0
  37. package/dist/scripts/diff-test.js +39 -0
  38. package/dist/scripts/engine-run.js +14 -0
  39. package/dist/scripts/engine-tests.js +45 -0
  40. package/dist/scripts/hbcli-tests.js +101 -0
  41. package/dist/scripts/incident-tests.js +96 -0
  42. package/dist/scripts/journey-tests.js +45 -0
  43. package/dist/scripts/ledger-tests.js +365 -0
  44. package/dist/scripts/ledger-workflow-crash.js +42 -0
  45. package/dist/scripts/memory-capture-tests.js +77 -0
  46. package/dist/scripts/memory-decay-tests.js +83 -0
  47. package/dist/scripts/memory-metrics.js +24 -0
  48. package/dist/scripts/nudge-digest.js +83 -0
  49. package/dist/scripts/opensky-check.js +20 -0
  50. package/dist/scripts/opensky-tests.js +52 -0
  51. package/dist/scripts/probe-poi-tests.js +88 -0
  52. package/dist/scripts/publish-preverify.js +68 -0
  53. package/dist/scripts/replay-async.js +44 -0
  54. package/dist/scripts/replay-real.js +38 -0
  55. package/dist/scripts/replay.js +116 -0
  56. package/dist/scripts/session-attach-diagnose.js +27 -0
  57. package/dist/scripts/session-attach-poc.js +65 -0
  58. package/dist/scripts/session-extract-tests.js +81 -0
  59. package/dist/scripts/session-tests.js +162 -0
  60. package/dist/scripts/skeleton-check.js +37 -0
  61. package/dist/scripts/skeleton-integration-test.js +22 -0
  62. package/dist/scripts/skills-contract-tests.js +85 -0
  63. package/dist/scripts/smoke.js +328 -0
  64. package/dist/scripts/state-cli-tests.js +136 -0
  65. package/dist/scripts/state-cli.js +248 -0
  66. package/dist/scripts/time-eval-tests.js +318 -0
  67. package/dist/scripts/travel-timeline-tests.js +123 -0
  68. package/dist/scripts/unified-tests.js +49 -0
  69. package/dist/scripts/weather-tests.js +48 -0
  70. package/dist/src/bridge.js +34 -0
  71. package/dist/src/companions.js +112 -0
  72. package/dist/src/contracts.js +64 -0
  73. package/dist/src/dsh-llm.js +174 -0
  74. package/dist/src/engine.js +331 -0
  75. package/dist/src/index.js +1239 -0
  76. package/dist/src/journey.js +147 -0
  77. package/dist/src/loop.js +360 -0
  78. package/dist/src/memory-capture.js +40 -0
  79. package/dist/src/memory-decay.js +31 -0
  80. package/dist/src/memory-utility.js +55 -0
  81. package/dist/src/mock-llm.js +103 -0
  82. package/dist/src/model.js +134 -0
  83. package/dist/src/slot-spec.js +165 -0
  84. package/dist/src/state-ledger.js +901 -0
  85. package/dist/src/time-anchor.js +100 -0
  86. package/dist/src/tool-packet.js +12 -0
  87. package/dist/src/travel-slots.js +144 -0
  88. package/dist/src/travel-timeline.js +78 -0
  89. package/dist/src/unified.js +538 -0
  90. package/dist/src/wish-pool.js +27 -0
  91. package/package.json +84 -0
  92. package/ts/capabilities/agent-reach-deep.ts +114 -0
  93. package/ts/capabilities/agent-reach.ts +158 -0
  94. package/ts/capabilities/anything.ts +201 -0
  95. package/ts/capabilities/flyai.ts +151 -0
  96. package/ts/capabilities/hbcli.ts +152 -0
  97. package/ts/capabilities/incident-log.ts +148 -0
  98. package/ts/capabilities/opensky.ts +121 -0
  99. package/ts/capabilities/session/action-cache.ts +148 -0
  100. package/ts/capabilities/session/adapters/ctrip-flight.ts +107 -0
  101. package/ts/capabilities/session/adapters/meituan-local.ts +70 -0
  102. package/ts/capabilities/session/extract.ts +54 -0
  103. package/ts/capabilities/session/read-guard.ts +141 -0
  104. package/ts/capabilities/session/transport.ts +114 -0
  105. package/ts/capabilities/session-search.ts +129 -0
  106. package/ts/capabilities/weather.ts +176 -0
  107. package/ts/package.json +27 -0
  108. package/ts/scripts/async-collect.ts +50 -0
  109. package/ts/scripts/skeleton-check.ts +49 -0
  110. package/ts/scripts/skeleton-integration-test.ts +23 -0
  111. package/ts/scripts/state-cli.ts +201 -0
  112. package/ts/src/bridge.ts +44 -0
  113. package/ts/src/companions.ts +113 -0
  114. package/ts/src/contracts.ts +165 -0
  115. package/ts/src/dsh-llm.ts +155 -0
  116. package/ts/src/index.ts +968 -0
  117. package/ts/src/loop.ts +411 -0
  118. package/ts/src/memory-capture.ts +59 -0
  119. package/ts/src/memory-decay.ts +55 -0
  120. package/ts/src/memory-utility.ts +70 -0
  121. package/ts/src/mock-llm.ts +100 -0
  122. package/ts/src/model.ts +220 -0
  123. package/ts/src/slot-spec.ts +172 -0
  124. package/ts/src/state-ledger.ts +849 -0
  125. package/ts/src/time-anchor.ts +134 -0
  126. package/ts/src/tool-packet.ts +32 -0
  127. package/ts/src/travel-slots.ts +219 -0
  128. package/ts/src/travel-timeline.ts +83 -0
  129. package/ts/src/unified.ts +582 -0
  130. package/ts/src/wish-pool.ts +66 -0
@@ -0,0 +1,114 @@
1
+ /**
2
+ * agent-reach 深度能力层:yt-dlp 字幕提取 + gh 仓库搜索(可选工具,系统有就调,没有降级)。
3
+ *
4
+ * 与 agent-reach.ts(r.jina.ai 读网页)互补——dsh LLM 的 3 个 agent-reach 工具:
5
+ * - gotry_web_search → r.jina.ai(已落,零配置)
6
+ * - gotry_video_subtitle → yt-dlp(本文件;系统装了就调,没装降级提示安装方式)
7
+ * - gotry_github_search → gh(本文件;系统装了就调,没装降级提示安装方式)
8
+ *
9
+ * 契约(与 anything/weather/opensky/agent-reach 同构,L4 不变量):
10
+ * - 永不抛错:spawn 失败/超时/工具未装一律降级返回;
11
+ * - 证据链标注:[agent-reach:yt-dlp@ts] / [agent-reach:gh@ts] / [@error@ts] / [@not-installed@ts];
12
+ * - 零强制依赖:不 npm install 任何东西,用系统 PATH 里的二进制。
13
+ */
14
+
15
+ import { spawn } from 'node:child_process'
16
+
17
+ export interface SpawnQuery {
18
+ /** 完整命令参数(不含 bin) */
19
+ args: string[]
20
+ /** 二进制名;默认按能力层定 */
21
+ bin?: string
22
+ /** 超时(ms);默认 30_000 */
23
+ timeoutMs?: number
24
+ }
25
+
26
+ export interface SpawnResult {
27
+ ok: boolean
28
+ /** found / not-installed / error / timeout */
29
+ verdict: 'found' | 'not-installed' | 'error' | 'timeout'
30
+ evidence: string
31
+ latencyMs: number
32
+ stdout?: string
33
+ stderr?: string
34
+ }
35
+
36
+ function run(bin: string, args: string[], timeoutMs: number): Promise<{ code: number | null; stdout: string; stderr: string; err?: string; timedOut: boolean }> {
37
+ return new Promise((resolve) => {
38
+ const child = spawn(bin, args, { env: process.env })
39
+ let stdout = ''
40
+ let stderr = ''
41
+ let err: string | undefined
42
+ let timedOut = false
43
+ const timer = setTimeout(() => {
44
+ timedOut = true
45
+ try { child.kill('SIGKILL') } catch { /* ignore */ }
46
+ }, timeoutMs)
47
+ child.stdout.on('data', (d: Buffer) => { stdout += d.toString() })
48
+ child.stderr.on('data', (d: Buffer) => { stderr += d.toString() })
49
+ child.on('error', (e) => { err = (e as Error).message })
50
+ child.on('close', (code) => {
51
+ clearTimeout(timer)
52
+ resolve({ code, stdout, stderr, err, timedOut })
53
+ })
54
+ })
55
+ }
56
+
57
+ const ts = () => new Date().toISOString()
58
+
59
+ /** yt-dlp 提取视频字幕(YouTube/B站;系统需已装 yt-dlp) */
60
+ export async function videoSubtitle(q: { url: string; lang?: string; timeoutMs?: number }): Promise<SpawnResult & { subtitles?: string }> {
61
+ const started = Date.now()
62
+ const lang = q.lang ?? 'zh-Hans,zh,en'
63
+ const args = ['--write-sub', '--write-auto-sub', '--sub-langs', lang, '--skip-download', '--sub-format', 'vtt', '-o', '/tmp/gotry-sub-%(id)s', q.url]
64
+ const r = await run('yt-dlp', args, q.timeoutMs ?? 60_000)
65
+ const latencyMs = Date.now() - started
66
+ // not-installed: ENOENT
67
+ if (r.err?.includes('ENOENT')) {
68
+ return { ok: false, verdict: 'not-installed', evidence: `[agent-reach:yt-dlp@not-installed@${ts()}]`, latencyMs, stderr: 'yt-dlp 未安装;装法: brew install yt-dlp 或 pip install yt-dlp' }
69
+ }
70
+ if (r.timedOut) {
71
+ return { ok: false, verdict: 'timeout', evidence: `[agent-reach:yt-dlp@error@${ts()}] timeout`, latencyMs }
72
+ }
73
+ if (r.code !== 0) {
74
+ return { ok: false, verdict: 'error', evidence: `[agent-reach:yt-dlp@error@${ts()}] exit ${r.code}`, latencyMs, stderr: r.stderr.slice(0, 400) }
75
+ }
76
+ // 从 stdout 抓写入的字幕文件名
77
+ const m = r.stdout.match(/Writing subtitles to:\s*(\S+)/)
78
+ if (!m) {
79
+ return { ok: false, verdict: 'error', evidence: `[agent-reach:yt-dlp@error@${ts()}] no subtitle file in output`, latencyMs, stderr: r.stdout.slice(0, 300) }
80
+ }
81
+ const { readFile } = await import('node:fs/promises')
82
+ let subtitles: string | undefined
83
+ try {
84
+ subtitles = (await readFile(m[1]!, 'utf-8')).slice(0, 8000)
85
+ } catch { /* 字幕文件读失败保留 undefined */ }
86
+ return { ok: true, verdict: 'found', evidence: `[agent-reach:yt-dlp@${ts()}]`, latencyMs, stdout: r.stdout.slice(0, 400), subtitles }
87
+ }
88
+
89
+ /** gh 搜索 GitHub 仓库(系统需已装 gh 并登录) */
90
+ export async function githubSearch(q: { query: string; limit?: number; timeoutMs?: number }): Promise<SpawnResult & { repos?: Array<{ name: string; description?: string; stars?: number; url?: string }> }> {
91
+ const started = Date.now()
92
+ const limit = q.limit ?? 5
93
+ const args = ['search', 'repos', q.query, '--json', 'fullName,description,stargazersCount,url', '--limit', String(limit)]
94
+ const r = await run('gh', args, q.timeoutMs ?? 30_000)
95
+ const latencyMs = Date.now() - started
96
+ if (r.err?.includes('ENOENT')) {
97
+ return { ok: false, verdict: 'not-installed', evidence: `[agent-reach:gh@not-installed@${ts()}]`, latencyMs, stderr: 'gh 未安装;装法: brew install gh && gh auth login' }
98
+ }
99
+ if (r.timedOut) {
100
+ return { ok: false, verdict: 'timeout', evidence: `[agent-reach:gh@error@${ts()}] timeout`, latencyMs }
101
+ }
102
+ if (r.code !== 0) {
103
+ return { ok: false, verdict: 'error', evidence: `[agent-reach:gh@error@${ts()}] exit ${r.code}`, latencyMs, stderr: r.stderr.slice(0, 300) }
104
+ }
105
+ try {
106
+ const repos = JSON.parse(r.stdout) as Array<{ fullName: string; description?: string; stargazersCount?: number; url?: string }>
107
+ return {
108
+ ok: true, verdict: 'found', evidence: `[agent-reach:gh@${ts()}]`, latencyMs,
109
+ repos: repos.map(x => ({ name: x.fullName, description: x.description, stars: x.stargazersCount, url: x.url })),
110
+ }
111
+ } catch {
112
+ return { ok: false, verdict: 'error', evidence: `[agent-reach:gh@error@${ts()}] parse fail`, latencyMs, stderr: r.stdout.slice(0, 300) }
113
+ }
114
+ }
@@ -0,0 +1,158 @@
1
+ /**
2
+ * agent-reach wrapper —— 上游 Panniantong/Agent-Reach 的薄壳(零渠道知识)。
3
+ *
4
+ * 分工(2026-08-22 founder 纠偏「wrapper 不是 router」):
5
+ * - 知识(渠道清单/方法签名/setup 指引)→ 上游:agent_reach.channels 注册表 + Channel.check() + guides/
6
+ * - 决策(该调哪个渠道哪个方法) → dsh 里的 LLM(靠本 wrapper 的自描述错误拿清单)
7
+ * - 管道(spawn/超时/永不抛错/证据链) → 本文件 + agent-reach-bridge.py,上游加渠道零改动
8
+ *
9
+ * 上游设计意图(Channel 基类原话):"After installation, agents call upstream tools directly."
10
+ * - python-API 渠道(web.read / v2ex.get_hot_topics / xueqiu.get_stock_quote / youtube.transcribe ...)
11
+ * 走本文件 reach() 的反射桥
12
+ * - 外部 CLI 工具渠道(github→gh / youtube 字幕→yt-dlp 等)由专用 dsh 工具(gotry_github_search /
13
+ * gotry_video_subtitle,见 agent-reach-deep.ts)充当执行面;上游无 python API 的不再自行拼命令
14
+ *
15
+ * 契约(L4 不变量,与 anything/weather/opensky 同构):
16
+ * - 永不抛错;verdict: found / not-installed / needs-setup / error
17
+ * - needs-setup 的 setup 文案 = 上游 check() 原话透传,不转述
18
+ * - 证据链 [agent-reach:<channel>.<method>@ts]
19
+ */
20
+
21
+ import { spawn } from 'node:child_process'
22
+ import { existsSync } from 'node:fs'
23
+ import { resolve } from 'node:path'
24
+
25
+ export interface ReachResult {
26
+ channel: string
27
+ method: string
28
+ ok: boolean
29
+ verdict: 'found' | 'not-installed' | 'needs-setup' | 'error'
30
+ evidence: string
31
+ latencyMs: number
32
+ data?: unknown
33
+ /** 上游清单(未知渠道→渠道表;未知方法→方法签名表),LLM 据此自纠 */
34
+ inventory?: Record<string, unknown>
35
+ /** 上游 check() 原话(仅 needs-setup 时) */
36
+ setup?: string
37
+ error?: string
38
+ }
39
+
40
+ const ts = () => new Date().toISOString()
41
+ const repoRoot = () => resolve(import.meta.dirname, '..', '..')
42
+ const venvPython = () => resolve(repoRoot(), '.venv/bin/python')
43
+ const bridgeScript = () => resolve(import.meta.dirname, 'agent-reach-bridge.py')
44
+
45
+ function run(bin: string, args: string[], timeoutMs: number): Promise<{ code: number | null; stdout: string; stderr: string; err?: string }> {
46
+ return new Promise((resolveRun) => {
47
+ const child = spawn(bin, args, { env: process.env })
48
+ let stdout = ''
49
+ let stderr = ''
50
+ let err: string | undefined
51
+ const timer = setTimeout(() => { try { child.kill('SIGKILL') } catch { /* ignore */ } }, timeoutMs)
52
+ child.stdout.on('data', (d: Buffer) => { stdout += d.toString() })
53
+ child.stderr.on('data', (d: Buffer) => { stderr += d.toString() })
54
+ child.on('error', (e) => { err = (e as Error).message })
55
+ child.on('close', (code) => { clearTimeout(timer); resolveRun({ code, stdout, stderr, err }) })
56
+ })
57
+ }
58
+
59
+ interface BridgeOut {
60
+ ok?: boolean
61
+ data?: unknown
62
+ error?: string
63
+ check?: { status?: string; message?: string }
64
+ channels?: Record<string, string>
65
+ methods?: Record<string, string>
66
+ channel?: Record<string, unknown>
67
+ }
68
+
69
+ /** 通用入口:反射调上游任意渠道方法;永不抛错 */
70
+ export async function reach(q: { channel: string; method: string; args?: string[] | string; timeoutMs?: number }): Promise<ReachResult> {
71
+ const started = Date.now()
72
+ const evidence = (state: string) => `[agent-reach:${q.channel}.${q.method}@${state === 'found' ? ts() : `${state}@${ts()}`}]`
73
+ const base = { channel: q.channel, method: q.method }
74
+
75
+ if (!q.channel || !q.method) {
76
+ return { ...base, ok: false, verdict: 'error', evidence: evidence('error'), latencyMs: 0, error: 'channel 与 method 必填(渠道/方法清单:先随便调一次,inventory 会带回上游清单)' }
77
+ }
78
+ const py = venvPython()
79
+ if (!existsSync(py)) {
80
+ return { ...base, ok: false, verdict: 'not-installed', evidence: evidence('not-installed'), latencyMs: 0, setup: 'gotry .venv 缺 python — 见 docs/tokens.md(agent-reach 安装)' }
81
+ }
82
+ const args = Array.isArray(q.args) ? q.args : (q.args ? q.args.split(/\s+/).filter(Boolean) : [])
83
+ const r = await run(py, [bridgeScript(), q.channel, q.method, ...args], q.timeoutMs ?? 30_000)
84
+ const latencyMs = Date.now() - started
85
+
86
+ let parsed: BridgeOut = {}
87
+ try {
88
+ parsed = JSON.parse((r.stdout || '').trim().split('\n').filter(Boolean).pop() ?? '{}') as BridgeOut
89
+ } catch { /* 走下面的兜底 */ }
90
+
91
+ if (r.err?.includes('ENOENT')) {
92
+ return { ...base, ok: false, verdict: 'not-installed', evidence: evidence('not-installed'), latencyMs, setup: '.venv/bin/python 不可执行(装 agent-reach: 见 docs/tokens.md)' }
93
+ }
94
+ if (parsed.ok === true) {
95
+ return { ...base, ok: true, verdict: 'found', evidence: evidence('found'), latencyMs, data: parsed.data }
96
+ }
97
+ // 上游 agent_reach 包未装(bridge 自己的 exit 3 输出)
98
+ if (parsed.error?.includes('agent_reach 未安装')) {
99
+ return { ...base, ok: false, verdict: 'not-installed', evidence: evidence('not-installed'), latencyMs, setup: '.venv 缺 agent-reach(pip install agent-reach,见 docs/tokens.md)' }
100
+ }
101
+ // 未知渠道/方法:自描述,附上游清单(不拦 LLM,让它看清单自纠)
102
+ if (r.code === 2) {
103
+ return {
104
+ ...base, ok: false, verdict: 'error', evidence: evidence('error'), latencyMs,
105
+ error: parsed.error,
106
+ inventory: parsed.channels ? { channels: parsed.channels } : { channel: parsed.channel, methods: parsed.methods },
107
+ }
108
+ }
109
+ // 调用抛错:若上游 check() 给出 warn/off,把上游原话作为 setup 透传
110
+ const checkStatus = parsed.check?.status
111
+ if (checkStatus === 'warn' || checkStatus === 'off') {
112
+ return { ...base, ok: false, verdict: 'needs-setup', evidence: evidence('needs-setup'), latencyMs, error: parsed.error, setup: parsed.check?.message }
113
+ }
114
+ return { ...base, ok: false, verdict: 'error', evidence: evidence('error'), latencyMs, error: parsed.error ?? r.stderr.slice(0, 200) }
115
+ }
116
+
117
+ /** 读网页:委托上游 WebChannel.read(Jina Reader 后端);供 gotry web 读取工具使用 */
118
+ export async function readUrl(q: { url: string; timeoutMs?: number }): Promise<{ ok: boolean; via: string; title?: string; content?: string; evidence: string; latencyMs: number; error?: string }> {
119
+ if (!/^https?:\/\//.test(q.url.trim())) {
120
+ return { ok: false, via: 'r.jina.ai-error', evidence: `[agent-reach:web.read@error@${ts()}] 非法 URL(需 http(s)://)`, latencyMs: 0, error: 'url 必须以 http(s):// 开头' }
121
+ }
122
+ const r = await reach({ channel: 'web', method: 'read', args: [q.url.trim()], timeoutMs: q.timeoutMs })
123
+ const content = typeof r.data === 'string' ? r.data.slice(0, 8000) : undefined
124
+ // 上游 Jina Reader 输出首行 "Title: ...",提取为标题(展示层逻辑,非渠道知识)
125
+ const title = content?.match(/^Title: (.+)$/m)?.[1]
126
+ return {
127
+ ok: r.ok, via: r.ok ? 'r.jina.ai' : 'r.jina.ai-error',
128
+ title, content, evidence: r.evidence, latencyMs: r.latencyMs,
129
+ error: r.error,
130
+ }
131
+ }
132
+
133
+ export interface ReachStatus {
134
+ ok: boolean
135
+ via: 'agent-reach-cli' | 'not-installed'
136
+ evidence: string
137
+ latencyMs: number
138
+ /** 上游 doctor 原始输出(渠道状态以上游为准,gotry 不自探测) */
139
+ output: string
140
+ }
141
+
142
+ /** 渠道体检:spawn 真 agent-reach doctor,输出原样透传 */
143
+ export async function reachStatus(timeoutMs = 90_000): Promise<ReachStatus> {
144
+ const started = Date.now()
145
+ const bin = resolve(repoRoot(), '.venv/bin/agent-reach')
146
+ if (!existsSync(bin)) {
147
+ return { ok: false, via: 'not-installed', evidence: `[agent-reach:doctor@not-installed@${ts()}]`, latencyMs: 0, output: 'pip install agent-reach 于 .venv(见 docs/tokens.md)' }
148
+ }
149
+ const r = await run(bin, ['doctor'], timeoutMs)
150
+ const output = (r.stdout || r.stderr).trim()
151
+ return {
152
+ ok: !r.err?.includes('ENOENT') && r.code === 0,
153
+ via: 'agent-reach-cli',
154
+ evidence: `[agent-reach:doctor@${ts()}] via .venv/bin/agent-reach`,
155
+ latencyMs: Date.now() - started,
156
+ output: output.slice(0, 4000),
157
+ }
158
+ }
@@ -0,0 +1,201 @@
1
+ /**
2
+ * Anything 通用搜索能力层:hbcli → hotel-be Anything 函数。
3
+ *
4
+ * 链路(同构 hbcli.ts):
5
+ * gotry capabilities/anything.ts
6
+ * → spawn hbcli search anything [keywords...] --json <opts>
7
+ * → hotel-be /api/search/anything
8
+ * → search/service/geography.go func Anything (mixed 城市+酒店搜索)
9
+ *
10
+ * 三值语义:
11
+ * - hit: 命中候选(混合 city/hotel,maybe-coords)
12
+ * - miss: hbcli exit 0,但 candidates 为空(查无结果)
13
+ * - err: hbcli exit ≠ 0 或超时(降级,降级产物 标 [实时API:hbcli-anything@error@ts])
14
+ *
15
+ * 契约(与 hbcli/weather/opensky 同构,L4 不变量):
16
+ * - 永不抛错:网络/超时/解析失败一律降级返回;
17
+ * - 证据链标注:成功 [实时API:hbcli-anything@ts];失败 [实时API:hbcli-anything@error@ts];
18
+ * - 永不阻塞调用方:周边有 12s 默认超时,LLM 不等。
19
+ *
20
+ * 第 7 个能力层(与 hbcli.ts / weather.ts / opensky.ts 平级),data-sources.md §4
21
+ * Google Place 链路的「酒店-be 中间层」入口。
22
+ */
23
+
24
+ import { spawn } from 'node:child_process'
25
+
26
+ export interface AnythingQuery {
27
+ /** 多词以空格 join,与 hbcli argument-parser 一致;前后 trim。空则报错(unless contentType 强限定) */
28
+ keyword: string
29
+ /** 'city' | 'hotel' | undefined(混合) */
30
+ contentType?: 'city' | 'hotel'
31
+ /** 限定子区域 ID(可选) */
32
+ parentDestinationId?: string | number
33
+ /** 默认 12_000 ms */
34
+ timeoutMs?: number
35
+ /** 显式 hbcli 路径(默认 'hbcli',从 $PATH 找) */
36
+ hbcliBin?: string
37
+ }
38
+
39
+ export interface AnythingHit {
40
+ type: 'city' | 'hotel' | 'place'
41
+ /** FuzzySearchItem name(or hotel name);fallback 为 region.Name */
42
+ name: string
43
+ /** score / 0..1(若返回) */
44
+ score?: number
45
+ /** lat/lng(若 region 给出) */
46
+ latitude?: number
47
+ longitude?: number
48
+ /** city id(若有) */
49
+ destinationId?: string
50
+ /** hotel id(若有) */
51
+ hotelId?: string
52
+ }
53
+
54
+ export interface AnythingResult {
55
+ ok: boolean
56
+ via: 'hbcli-anything' | 'hbcli-anything-error'
57
+ evidence: string
58
+ latencyMs: number
59
+ /** 三值 */
60
+ verdict: 'hit' | 'miss' | 'error'
61
+ hits?: AnythingHit[]
62
+ totalCandidates?: number
63
+ error?: string
64
+ }
65
+
66
+ interface RawSearchResp {
67
+ candidates?: RawSearchItem[]
68
+ }
69
+ interface RawSearchItem {
70
+ type?: string
71
+ name?: string
72
+ label?: string
73
+ region?: { id?: string; name?: string; latitude?: number; longitude?: number; typeScore?: number; name_en?: string; name_zh?: string }
74
+ hotel?: { id?: string; name?: string; latitude?: number; longitude?: number }
75
+ score?: number
76
+ }
77
+
78
+ function sh(cmd: string, args: string[], opts: { timeoutMs: number; env: NodeJS.ProcessEnv }) {
79
+ const ctrl = new AbortController()
80
+ const timer = setTimeout(() => {
81
+ ctrl.abort()
82
+ // AbortSignal on spawn 会发 SIGTERM;对于不响应 SIGTERM 的子进程,需要在外部再发一次 SIGKILL。
83
+ // Node 不能直接通过 AbortController 拿 child pid,所以需要借助外部变量保存 child。
84
+ }, opts.timeoutMs)
85
+ let child: ReturnType<typeof spawn> | null = null
86
+ let killTimer: NodeJS.Timeout | null = null
87
+ return new Promise<{ code: number; stdout: string; stderr: string; error?: string }>((resolve) => {
88
+ child = spawn(cmd, args, {
89
+ env: opts.env,
90
+ cwd: process.cwd(),
91
+ signal: ctrl.signal,
92
+ })
93
+ let stdout = ''
94
+ let stderr = ''
95
+ let error: string | undefined
96
+ let killed = false
97
+ child.stdout?.on('data', (d: Buffer) => { stdout += d.toString() })
98
+ child.stderr?.on('data', (d: Buffer) => { stderr += d.toString() })
99
+ child.on('error', (e) => { error = (e as Error).message.slice(0, 200) })
100
+ child.on('close', (code) => {
101
+ clearTimeout(timer)
102
+ if (killTimer) clearTimeout(killTimer)
103
+ resolve({ code: killed ? -2 : (code ?? -1), stdout, stderr, error })
104
+ })
105
+ // Abort 后 500ms 内若子进程仍活,发 SIGKILL 强杀。
106
+ ctrl.signal.addEventListener('abort', () => {
107
+ if (!child) return
108
+ try {
109
+ child.kill('SIGTERM')
110
+ } catch { /* ignore */ }
111
+ killTimer = setTimeout(() => {
112
+ try { child?.kill('SIGKILL') } catch { /* ignore */ }
113
+ }, 500)
114
+ })
115
+ })
116
+ }
117
+
118
+ const upperFirst = (s: string): string => (s ? s[0]!.toUpperCase() + s.slice(1) : s)
119
+ const lowerFirst = (s: string): string => (s ? s[0]!.toLowerCase() + s.slice(1) : s)
120
+
121
+ /** Anything 通用搜索 — 任何搜索失败走降级;不抛错 */
122
+ export async function anythingSearch(q: AnythingQuery): Promise<AnythingResult> {
123
+ const started = Date.now()
124
+ const ts = new Date().toISOString()
125
+ const kw = (q.keyword ?? '').trim()
126
+ if (!kw) {
127
+ return {
128
+ ok: false, via: 'hbcli-anything-error',
129
+ evidence: `[实时API:hbcli-anything@error@${ts}] keyword empty`,
130
+ latencyMs: Date.now() - started, verdict: 'error',
131
+ error: 'keyword is required',
132
+ }
133
+ }
134
+ const bin = q.hbcliBin ?? 'hbcli'
135
+ const args = [bin, 'search', 'anything', kw]
136
+ if (q.contentType) args.push('--content-type', q.contentType)
137
+ if (q.parentDestinationId !== undefined) {
138
+ args.push('--parent-destination-id', String(q.parentDestinationId))
139
+ }
140
+
141
+ const r = await sh(bin, args.slice(1), {
142
+ timeoutMs: q.timeoutMs ?? 12_000,
143
+ env: process.env,
144
+ })
145
+ const latencyMs = Date.now() - started
146
+
147
+ if (r.error || r.code !== 0) {
148
+ return {
149
+ ok: false,
150
+ via: 'hbcli-anything-error',
151
+ evidence: `[实时API:hbcli-anything@error@${ts}] ${r.error ?? `exit ${r.code}`}`,
152
+ latencyMs,
153
+ verdict: 'error',
154
+ error: r.error ?? `${r.stderr.slice(0, 200)} (exit ${r.code})`,
155
+ }
156
+ }
157
+
158
+ let data: RawSearchResp
159
+ try {
160
+ const jStart = r.stdout.indexOf('{') >= 0 ? r.stdout.indexOf('{') : r.stdout.indexOf('[')
161
+ const json = jStart >= 0 ? r.stdout.slice(jStart) : r.stdout
162
+ data = JSON.parse(json) as RawSearchResp
163
+ } catch {
164
+ return {
165
+ ok: false, via: 'hbcli-anything-error',
166
+ evidence: `[实时API:hbcli-anything@error@${ts}] parse failed`,
167
+ latencyMs,
168
+ verdict: 'error',
169
+ error: 'failed to parse hbcli output as JSON',
170
+ }
171
+ }
172
+
173
+ const rawItems = data.candidates ?? []
174
+ const hits: AnythingHit[] = rawItems.map((it) => {
175
+ const region = it.region ?? {}
176
+ const hotel = it.hotel ?? {}
177
+ const isHotel = it.type === 'hotel' || it.type === 'SupplierHotelList' || Boolean(hotel.id)
178
+ return {
179
+ type: (isHotel ? 'hotel' : (it.type === 'place' || it.type === 'City' ? lowerFirst(it.type) : 'city')) as AnythingHit['type'],
180
+ name: it.name ?? region.name_en ?? region.name ?? hotel.name ?? '?',
181
+ score: it.score ?? region.typeScore,
182
+ latitude: hotel.latitude ?? region.latitude,
183
+ longitude: hotel.longitude ?? region.longitude,
184
+ destinationId: region.id,
185
+ hotelId: hotel.id,
186
+ }
187
+ })
188
+
189
+ const verdict: AnythingResult['verdict'] = hits.length > 0 ? 'hit' : 'miss'
190
+ return {
191
+ ok: true, via: 'hbcli-anything',
192
+ evidence: `[实时API:hbcli-anything@${ts}] ${hits.length}/${rawItems.length} candidates`,
193
+ latencyMs,
194
+ verdict,
195
+ hits,
196
+ totalCandidates: rawItems.length,
197
+ }
198
+ }
199
+
200
+ // noop exports to keep file shape consistent with hbcli.ts
201
+ export const _ = { upperFirst }
@@ -0,0 +1,151 @@
1
+ /**
2
+ * FlyAI 官方通道能力层(飞猪开放平台,data-sources.md §8 / RFC user-session-data-rfc.md P0):
3
+ *
4
+ * 链路(同构 anything.ts 的 CLI spawn 模式):
5
+ * gotry capabilities/flyai.ts
6
+ * → spawn `npx -y @fly-ai/flyai-cli search-flight|search-train --origin X --destination Y --dep-date D`
7
+ * → 飞猪 MCP API(实时直连官方商品库)
8
+ *
9
+ * 契约(与 hbcli/weather/opensky/anything 同构,L4 不变量):
10
+ * - 只读:8 工具全只读,交易经 jumpUrl 由人完成(与 WriteGate 哲学同构);
11
+ * - 永不抛错:网络/超时/解析失败一律降级返回 verdict='error';
12
+ * - 证据链:成功 [实时API:flyai@ts];失败 [实时API:flyai@error@ts];
13
+ * - 无 key 可用;FLYAI_API_KEY 为可选增强(env 透传)。
14
+ */
15
+
16
+ import { spawn } from 'node:child_process'
17
+
18
+ export type FlyaiKind = 'flight' | 'train'
19
+
20
+ export interface FlyaiQuery {
21
+ kind: FlyaiKind
22
+ /** 城市名(中文),如 上海 / 丽江 */
23
+ origin: string
24
+ destination: string
25
+ /** YYYY-MM-DD */
26
+ depDate: string
27
+ /** 默认 30_000 ms(npx 冷启动 + 远端检索) */
28
+ timeoutMs?: number
29
+ /** 显式 CLI bin(默认 npx -y @fly-ai/flyai-cli) */
30
+ cliBin?: string
31
+ }
32
+
33
+ export interface FlyaiOption {
34
+ /** 航班号/车次,如 9C6617 / G201 */
35
+ no: string
36
+ /** 承运:吉祥航空 / 高铁 */
37
+ name: string
38
+ depDateTime: string
39
+ arrDateTime: string
40
+ depStation: string
41
+ arrStation: string
42
+ durationMin: number
43
+ price: number
44
+ seatClass?: string
45
+ /** 飞猪侧预订跳转(由人完成,agent 不碰) */
46
+ jumpUrl?: string
47
+ /** 打码价格原值(如 "1xxx"——未鉴权态火车价被模糊化,真实价以 jumpUrl 落地页为准) */
48
+ priceRaw?: string
49
+ }
50
+
51
+ export interface FlyaiResult {
52
+ ok: boolean
53
+ via: 'flyai' | 'flyai-error'
54
+ evidence: string
55
+ latencyMs: number
56
+ verdict: 'hit' | 'miss' | 'error'
57
+ kind: FlyaiKind
58
+ options?: FlyaiOption[]
59
+ error?: string
60
+ }
61
+
62
+ interface RawItem {
63
+ journeys?: Array<{
64
+ journeyType?: string
65
+ segments?: Array<{
66
+ marketingTransportName?: string
67
+ marketingTransportNo?: string
68
+ depDateTime?: string
69
+ arrDateTime?: string
70
+ depStationName?: string
71
+ arrStationName?: string
72
+ duration?: string | number
73
+ seatClassName?: string
74
+ }>
75
+ }>
76
+ ticketPrice?: string
77
+ /** 火车条目用顶层 price(且未鉴权态为打码串如 "1xxx") */
78
+ price?: string
79
+ jumpUrl?: string
80
+ }
81
+
82
+ function sh(cmd: string, args: string[], opts: { timeoutMs: number }) {
83
+ const child = spawn(cmd, args, { env: process.env, cwd: process.cwd() })
84
+ let stdout = ''
85
+ let stderr = ''
86
+ let error: string | undefined
87
+ const timer = setTimeout(() => {
88
+ try { child.kill('SIGKILL') } catch { /* ignore */ }
89
+ }, opts.timeoutMs)
90
+ return new Promise<{ code: number; stdout: string; stderr: string; error?: string; timedOut?: boolean }>((resolve) => {
91
+ child.stdout?.on('data', (d: Buffer) => { stdout += d.toString() })
92
+ child.stderr?.on('data', (d: Buffer) => { stderr += d.toString() })
93
+ child.on('error', (e) => { error = (e as Error).message.slice(0, 200) })
94
+ child.on('close', (code) => {
95
+ clearTimeout(timer)
96
+ resolve({ code: code ?? -1, stdout, stderr, error })
97
+ })
98
+ }).finally(() => clearTimeout(timer))
99
+ }
100
+
101
+ /** FlyAI 官方只读检索(机/火车票) — 任何失败走降级;不抛错 */
102
+ export async function flyaiSearch(q: FlyaiQuery): Promise<FlyaiResult> {
103
+ const started = Date.now()
104
+ const ts = new Date().toISOString()
105
+ const base = { kind: q.kind, latencyMs: 0 }
106
+ const origin = (q.origin ?? '').trim()
107
+ const destination = (q.destination ?? '').trim()
108
+ if (!origin || !destination || !/^\d{4}-\d{2}-\d{2}$/.test(q.depDate ?? '')) {
109
+ return { ...base, ok: false, via: 'flyai-error', verdict: 'error', evidence: `[实时API:flyai@error@${ts}] bad args`, error: 'origin/destination/depDate(YYYY-MM-DD) required' }
110
+ }
111
+ const sub = q.kind === 'flight' ? 'search-flight' : 'search-train'
112
+ const r = await sh(q.cliBin ?? 'npx', ['-y', '@fly-ai/flyai-cli', sub, '--origin', origin, '--destination', destination, '--dep-date', q.depDate], {
113
+ timeoutMs: q.timeoutMs ?? 30_000,
114
+ })
115
+ const latencyMs = Date.now() - started
116
+ if (r.error || r.code !== 0) {
117
+ return { ...base, latencyMs, ok: false, via: 'flyai-error', verdict: 'error', evidence: `[实时API:flyai@error@${ts}] ${r.error ?? `exit ${r.code}`}`, error: r.error ?? r.stderr.replace(/\s+/g, ' ').slice(0, 200) }
118
+ }
119
+ let items: RawItem[]
120
+ try {
121
+ const jStart = r.stdout.indexOf('{')
122
+ const parsed = JSON.parse(jStart >= 0 ? r.stdout.slice(jStart) : r.stdout) as { data?: { itemList?: RawItem[] } }
123
+ items = parsed.data?.itemList ?? []
124
+ } catch {
125
+ // 实测(2026-08-28):Sentinel 限流时 CLI exit=0 但 stdout 是 {"message":"SentinelBlockException..."}
126
+ const raw = r.stdout.replace(/\s+/g, ' ').slice(0, 160)
127
+ return { ...base, latencyMs, ok: false, via: 'flyai-error', verdict: 'error', evidence: `[实时API:flyai@error@${ts}] parse failed: ${raw}`, error: `failed to parse flyai output as JSON: ${raw}` }
128
+ }
129
+ const options: FlyaiOption[] = []
130
+ for (const it of items) {
131
+ const seg = it.journeys?.[0]?.segments?.[0]
132
+ if (!seg?.marketingTransportNo || !seg.depDateTime) continue
133
+ const rawPrice = it.ticketPrice ?? it.price ?? ''
134
+ const numericPrice = Number(rawPrice)
135
+ options.push({
136
+ no: seg.marketingTransportNo,
137
+ name: seg.marketingTransportName ?? '',
138
+ depDateTime: seg.depDateTime ?? '',
139
+ arrDateTime: seg.arrDateTime ?? '',
140
+ depStation: seg.depStationName ?? '',
141
+ arrStation: seg.arrStationName ?? '',
142
+ durationMin: Number(seg.duration ?? 0) || 0,
143
+ price: Number.isFinite(numericPrice) && numericPrice > 0 ? numericPrice : 0,
144
+ priceRaw: /^\d+$/.test(rawPrice) ? undefined : rawPrice || undefined,
145
+ seatClass: seg.seatClassName,
146
+ jumpUrl: it.jumpUrl,
147
+ })
148
+ }
149
+ const verdict: FlyaiResult['verdict'] = options.length > 0 ? 'hit' : 'miss'
150
+ return { kind: q.kind, latencyMs, ok: true, via: 'flyai', verdict, evidence: `[实时API:flyai@${ts}] ${options.length}/${items.length} ${q.kind} options`, options }
151
+ }