@danceiny/gotry 0.0.1-rc.5

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,152 @@
1
+ /**
2
+ * hotelbyte-cli(hbcli)能力层封装:进程内 spawn bun + hbcli --json。
3
+ *
4
+ * 拿到外部资源信息能力的三条路径:
5
+ * 1. 实时 hbcli 调用(能力强,延迟 1-3s,需凭证 + 证书有效)
6
+ * 2. 静态数据包(data/hotels_2026.json)——证据标注 [静态包:估算]
7
+ * 3. 简易降级返回(什么都不查得到时,提供"工具暂不可用"而不是抛错)
8
+ *
9
+ * 证书/凭证/网络问题一律降级,不抛到调用方——能力层契约:永远返回一种结果。
10
+ *
11
+ * §7-1/§7-2 L4 不变量:证据链标注([实时API:hbcli@<ts>] / [静态包:估算]);
12
+ * 估算必须显式标记。这是 L4 与 L1 透明卡片的接缝。
13
+ */
14
+
15
+ import { spawn } from 'node:child_process'
16
+
17
+ export interface HbcliCallOptions {
18
+ /** hbcli 二进制路径(默认 'hbcli',依赖 PATH) */
19
+ hbcliBin?: string
20
+ /** 超时(ms) */
21
+ timeoutMs?: number
22
+ /** 凭证(token)——空时不传,hbcli 用本地默认 */
23
+ token?: string
24
+ /** 环境(uat/prod/dev);默认 uat */
25
+ env?: 'uat' | 'prod' | 'dev'
26
+ }
27
+
28
+ export interface HbcliCallResult {
29
+ /** 是否走了实时 hbcli */
30
+ via: 'hbcli-realtime' | 'hbcli-cache' | 'hbcli-error'
31
+ /** hbcli 退码 0=成功 */
32
+ exitCode: number
33
+ /** 解析后的 JSON(若 --json 模式输出可解析;否则为空) */
34
+ result: unknown
35
+ /** 证据链标注(L4 契约;无论成功失败都填) */
36
+ evidence: string
37
+ /** hbcli 原始 stdout/stderr(供调试,2000 字符上限) */
38
+ stdout?: string
39
+ stderr?: string
40
+ /** 经过的时长 */
41
+ latencyMs: number
42
+ /** 错误时降级原因 */
43
+ error?: string
44
+ }
45
+
46
+ /** 通用 hbcli JSON 调用封装:失败不抛,而是返回降级结果 */
47
+ export async function callHbcliJson(
48
+ args: string[],
49
+ opts: HbcliCallOptions = {},
50
+ ): Promise<HbcliCallResult> {
51
+ 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
+ return new Promise((resolve) => {
59
+ let stdout = ''
60
+ let stderr = ''
61
+ let settled = false
62
+ const child = spawn(bin, args, { env: { ...process.env, ...envVars } })
63
+ const timer = setTimeout(() => {
64
+ if (!settled) {
65
+ settled = true
66
+ child.kill('SIGKILL')
67
+ resolve({
68
+ via: 'hbcli-error', exitCode: -1, result: null,
69
+ evidence: `[实时API:hbcli@timeout@${new Date().toISOString()}]`,
70
+ latencyMs: Date.now() - started, error: `timeout after ${timeoutMs}ms`,
71
+ })
72
+ }
73
+ }, timeoutMs)
74
+ child.stdout.on('data', (d: Buffer) => { stdout += d.toString() })
75
+ child.stderr.on('data', (d: Buffer) => { stderr += d.toString() })
76
+ child.on('close', (code) => {
77
+ if (settled) return
78
+ settled = true
79
+ clearTimeout(timer)
80
+ const latencyMs = Date.now() - started
81
+ if (code !== 0) {
82
+ // 不抛错,降级返回。证据链显式标注:实时 API 调用失败+原因+时间戳。
83
+ resolve({
84
+ via: 'hbcli-error', exitCode: code ?? -1, result: null,
85
+ evidence: `[实时API:hbcli@error@${new Date().toISOString()}]`,
86
+ stderr: stderr.slice(0, 2000),
87
+ latencyMs, error: stderr.trim().slice(0, 200) || `exit ${code}`,
88
+ })
89
+ return
90
+ }
91
+ // 尝试 JSON 解析
92
+ const jStart = stdout.search(/[\{\[]/)
93
+ const jsonStr = jStart >= 0 ? stdout.slice(jStart) : ''
94
+ let result: unknown = null
95
+ if (jsonStr) {
96
+ try { result = JSON.parse(jsonStr) } catch { /* 非 JSON 输出,留给调用方处理 */ }
97
+ }
98
+ resolve({
99
+ via: 'hbcli-realtime', exitCode: 0, result,
100
+ evidence: `[实时API:hbcli@${new Date().toISOString()}]`,
101
+ stdout: stdout.slice(0, 2000), latencyMs,
102
+ })
103
+ })
104
+ child.on('error', (e) => {
105
+ if (settled) return
106
+ settled = true
107
+ clearTimeout(timer)
108
+ // ENOENT (二进制不存在) 等也走降级路径
109
+ resolve({
110
+ via: 'hbcli-error', exitCode: -1, result: null,
111
+ evidence: `[实时API:hbcli@spawn_error@${new Date().toISOString()}]`,
112
+ latencyMs: Date.now() - started, error: (e as Error).message,
113
+ })
114
+ })
115
+ })
116
+ }
117
+
118
+ /** 高层语义化封装:酒店列表查询(down-tier to 静态包 + 证据链标注) */
119
+ export async function searchHotels(
120
+ query: { destination: string; checkIn?: string; checkOut?: string; adults?: number },
121
+ opts: HbcliCallOptions & { fallbackPath?: string } = {},
122
+ ): Promise<HbcliCallResult & { hotels?: unknown; summary: string }> {
123
+ const hbArgs = ['search', 'hotel-list', '--json']
124
+ if (query.destination) hbArgs.push('--destination', query.destination)
125
+ if (query.checkIn) hbArgs.push('--check-in', query.checkIn)
126
+ if (query.checkOut) hbArgs.push('--check-out', query.checkOut)
127
+ if (query.adults) hbArgs.push('--adults', String(query.adults))
128
+ const live = await callHbcliJson(hbArgs, opts)
129
+ if (live.via === 'hbcli-realtime') {
130
+ return { ...live, hotels: live.result, summary: `${query.destination}:hbcli 实时返回` }
131
+ }
132
+ // 降级:读静态包
133
+ const fallback = opts.fallbackPath
134
+ if (fallback) {
135
+ try {
136
+ const { readFile } = await import('node:fs/promises')
137
+ const pack = JSON.parse(await readFile(fallback, 'utf-8')) as Record<string, unknown>
138
+ return {
139
+ ...live,
140
+ hotels: pack,
141
+ summary: `${query.destination}:hbcli 不可用(${live.error ?? live.via}),降级到静态包`,
142
+ }
143
+ } catch { /* 静态包读不到也优雅降级 */ }
144
+ }
145
+ return { ...live, summary: `${query.destination}:hbcli 不可用且无静态包(仅返回错误)` }
146
+ }
147
+
148
+ /** 高层语义化封装:目的地列表(无数据依赖,通常 hbcli dest 命令可独立调通) */
149
+ export async function listDestinations(opts: HbcliCallOptions = {}): Promise<HbcliCallResult & { destinations?: unknown }> {
150
+ const r = await callHbcliJson(['search', 'destinations', '--json'], opts)
151
+ return { ...r, destinations: r.result }
152
+ }
@@ -0,0 +1,145 @@
1
+ /**
2
+ * 进程级事故日志(D-NEW 护栏):fsync append-only JSONL。
3
+ *
4
+ * 目的: dsh 0.1.1-rc.1 只接 SIGINT/SIGTERM,缺 uncaughtException/unhandledRejection——
5
+ * 任一插件 wasm/runtime 异常穿透到 node 层都会让整个 web UI 进程死亡,
6
+ * 现场不存。这次(Z3 WASM mk_bool_var 崩溃)教训:连个取证都没有。
7
+ *
8
+ * 设计: 进程级 handler 同步写盘(fsync),尽量在进程被杀前留下事故证据。
9
+ * handler 自身不再抛——再次抛出会绕过 handler,变成无声死亡。
10
+ *
11
+ * §11 状态面同步:事故日志是 gotry-state/incidents.jsonl,
12
+ * 用户可见(README 第 x 节),可清理。
13
+ */
14
+
15
+ import { appendFileSync, existsSync, mkdirSync, writeFileSync } from 'node:fs'
16
+ import { dirname, isAbsolute, join } from 'node:path'
17
+ import { fileURLToPath } from 'node:url'
18
+
19
+ export type IncidentKind = 'uncaughtException' | 'unhandledRejection' | 'plugin_error' | 'tool_execute_error'
20
+
21
+ export interface Incident {
22
+ ts: string
23
+ kind: IncidentKind
24
+ message: string
25
+ stack?: string
26
+ /** 来自哪个来源(plugin_name / service url 等) */
27
+ source?: string
28
+ }
29
+
30
+ /** 解析 gotry-state 绝对路径(stateRoot 可相对也可绝对) */
31
+ export function resolveIncidentsPath(stateRoot: string, filename = 'incidents.jsonl'): string {
32
+ const root = isAbsolute(stateRoot)
33
+ ? stateRoot
34
+ : join(process.cwd(), stateRoot)
35
+ return join(root, 'gotry-state', filename)
36
+ }
37
+
38
+ function ensureDir(path: string): void {
39
+ const dir = dirname(path)
40
+ if (!existsSync(dir)) mkdirSync(dir, { recursive: true })
41
+ }
42
+
43
+ let installed = false
44
+ let logPath: string | null = null
45
+
46
+ /**
47
+ * 写入一条事故并 fsync。同步执行——handler 内异步无效。
48
+ * 自身 try/catch 自保,不让写入失败再抛一次把进程杀掉。
49
+ */
50
+ export function recordIncident(inc: Incident, stateRoot?: string): boolean {
51
+ const target = stateRoot ? resolveIncidentsPath(stateRoot) : (logPath ?? resolveIncidentsPath('.'))
52
+ try {
53
+ ensureDir(target)
54
+ appendFileSync(target, JSON.stringify(inc) + '\n', { flag: 'a' })
55
+ // fsync: 让盘数据落定,免得被杀时缓冲丢
56
+ const fd = require('node:fs').openSync(target, 'a')
57
+ require('node:fs').fsyncSync(fd)
58
+ require('node:fs').closeSync(fd)
59
+ return true
60
+ } catch {
61
+ // 绝对不能在这里抛——我们正在处理已经抛过一次的进程级错误
62
+ return false
63
+ }
64
+ }
65
+
66
+ /**
67
+ * 安装进程级护栏:进程整个生命周期仅生效一次(重复调用安全)。
68
+ * 必须同步执行——异步发生在事件循环下一次,而 wasm 崩溃后已无下一轮。
69
+ *
70
+ * @returns 一个 disposer,卸载所有监听(测试场景使用)
71
+ */
72
+ export function installProcessGuards(stateRoot: string, labels?: { uncaughtException?: string; unhandledRejection?: string }): () => void {
73
+ if (installed) return () => { /* 已装,no-op */ }
74
+ installed = true
75
+ logPath = resolveIncidentsPath(stateRoot)
76
+
77
+ const uncaughtHandler = (err: Error, origin: NodeJS.UncaughtExceptionOriginTypes | 'uncaughtException') => {
78
+ recordIncident({
79
+ ts: new Date().toISOString(),
80
+ kind: 'uncaughtException',
81
+ message: (err?.message ?? String(err)).slice(0, 2000),
82
+ stack: (err?.stack ?? '').slice(0, 4000),
83
+ source: labels?.uncaughtException ?? String(origin),
84
+ }, stateRoot)
85
+ // 不调用 process.exit——让 dsh/上级容器的重启策略负责;
86
+ // 如果 dsh 不接住,我们至少留下事故记录。
87
+ }
88
+
89
+ const rejectionHandler = (reason: unknown, promise: Promise<unknown>) => {
90
+ const msg = reason instanceof Error ? reason.message : String(reason)
91
+ const stack = reason instanceof Error ? (reason.stack ?? '') : ''
92
+ recordIncident({
93
+ ts: new Date().toISOString(),
94
+ kind: 'unhandledRejection',
95
+ message: msg.slice(0, 2000),
96
+ stack: stack.slice(0, 4000),
97
+ source: labels?.unhandledRejection ?? 'promise',
98
+ }, stateRoot)
99
+ }
100
+
101
+ process.on('uncaughtException', uncaughtHandler)
102
+ process.on('unhandledRejection', rejectionHandler)
103
+ return () => {
104
+ process.off('uncaughtException', uncaughtHandler)
105
+ process.off('unhandledRejection', rejectionHandler)
106
+ installed = false
107
+ }
108
+ }
109
+
110
+ /**
111
+ * 工具执行面异常隔离(D-NEW gotry 侧收尾):dsh 的一个工具 execute 抛错/拒绝
112
+ * 会沿 cordis 传到主循环,拖垮整个会话。包装后:降级为结构化错误返回给 LLM、
113
+ * 事故落盘,永不向上抛。落盘失败也不抛(双保险,仍返回结构化错误)。
114
+ */
115
+ export function guardToolExecute<A, R>(name: string, stateRoot: string, execute: (args: A, exec: unknown) => R | Promise<R>): (args: A, exec: unknown) => Promise<R> {
116
+ return async (args: A, exec: unknown): Promise<R> => {
117
+ try {
118
+ return await execute(args, exec)
119
+ } catch (e) {
120
+ const err = e instanceof Error ? e : new Error(String(e))
121
+ recordIncident({
122
+ ts: new Date().toISOString(),
123
+ kind: 'tool_execute_error',
124
+ message: `${name}: ${err.message}`.slice(0, 2000),
125
+ stack: (err.stack ?? '').slice(0, 4000),
126
+ source: name,
127
+ }, stateRoot)
128
+ return {
129
+ ok: false,
130
+ summary: `gotry_${name} 内部错误(已隔离,会话继续): ${err.message.slice(0, 300)}`,
131
+ evidence: `[incident:tool_execute_error@${new Date().toISOString()}]`,
132
+ } as R
133
+ }
134
+ }
135
+ }
136
+
137
+ /** CLI 调试:从命令行单独跑查看当前进程是否已挂护栏 */
138
+ if (import.meta.url === `file://${fileURLToPath(import.meta.url)}` && process.argv[2] === '--smoke') {
139
+ const stateRoot = process.argv[3] ?? '.'
140
+ installProcessGuards(stateRoot, { uncaughtException: 'smoke', unhandledRejection: 'smoke' })
141
+ writeFileSync(resolveIncidentsPath(stateRoot) + '.smoke', 'ok')
142
+ // 触发一次未捕获,应被写盘(不真正杀进程——进程继续跑完)
143
+ setTimeout(() => Promise.reject(new Error('intentional smoke-rejection')), 50)
144
+ setTimeout(() => { console.log('SMOKE OK'); process.exit(0) }, 500)
145
+ }
@@ -0,0 +1,36 @@
1
+ # GoTry × dsh 成品组合(DeepSeek 原生——创始人官方 key)
2
+ # 用法(在 ts/dsh-runtime 下):
3
+ # DEEPSEEK_API_KEY=<key> node node_modules/@deepseek-ai/dsh/lib/bin.js \
4
+ # --profile headless --patch ../cordis.gotry-patch.yml "<任务>"
5
+ # (web 界面: 把 --profile headless 换成 web)
6
+ - insert:
7
+ - id: gotry-tools
8
+ name: '/Users/bytedance/work/gotry/ts/src/index.ts'
9
+ config:
10
+ pythonBin: '../../.venv/bin/python'
11
+ pythonPath: '../../py'
12
+ stateRoot: '.'
13
+ timeoutMs: 30000
14
+ preferInProcess: true
15
+ hbcliBin: 'hbcli'
16
+
17
+ # GoTry 成品人格:行为契约=产品语义
18
+ - id: system-prompt
19
+ config:
20
+ persona: >-
21
+ 你是 GoTry——从出发到下一次出发的 AI 旅行伙伴(中国出境首发,workation 场景)。
22
+ 今天是 {{current_date}}。行为契约:
23
+ (1)动机先行:首轮必问工作窗口(几点到几点/什么时区)与已订资源(航班/酒店);
24
+ (2)日历一次断言:日期换算 2026 年星期并记住,冲突让用户裁决,永不重算;
25
+ (3)翻译不造数:班次/价格只来自工具(gotry_feasibility_check/gotry_skeleton_check/gotry_hotel_search);
26
+ (4)判定归引擎:可行性/全成本由工具计算,你只解释;
27
+ (5)待决=选择题:2-3 个带 trade-off 选项;
28
+ (6)憧憬不被拒绝:不可行进 gotry_wish_pool_add(带条件);
29
+ (7)证据链:引用工具返回的标注([骨架:openflights]/[实时API:hbcli]/[静态包:估算])。
30
+ (8)时间感知:推荐季节/月份选项时,以今天为锚点——已过的月份不说「将」,
31
+ 当下所在的季节直接点明(如「现在正是雨季」而非把当前月份当成远期选项)。
32
+ (9)兜底链:自身工具(可行性/酒店/天气/航班/Anything)答不上来时,用
33
+ gotry_agent_reach(100% follow Panniantong/Agent-Reach 路由表):web 读任意网页(r.jina.ai)/
34
+ rss/v2ex/youtube 字幕(yt-dlp)/github 搜索(gh)/bilibili/exa 语义搜索;需登录态的渠道
35
+ (twitter/reddit/xhs 等)会返回配置指引——如实转告用户,不编造。action=status 可跑
36
+ 上游 `agent-reach doctor` 体检。答案标 [agent-reach:<渠道>@<ts>]。
@@ -0,0 +1,23 @@
1
+ {
2
+ "name": "@gotry/plugin",
3
+ "version": "0.0.1-rc.2",
4
+ "private": true,
5
+ "description": "GoTry dsh plugin: motivation profile, feasibility (door-to-door true cost via unified segment model), skeleton connectivity, hotel search, wish pool — all pure TS, no Python runtime required",
6
+ "type": "module",
7
+ "main": "src/index.ts",
8
+ "scripts": {
9
+ "typecheck": "tsc --noEmit",
10
+ "smoke": "tsx scripts/smoke.ts"
11
+ },
12
+ "dependencies": {
13
+ "@deepseek-ai/cordis": "*",
14
+ "@deepseek-ai/dsh-tools": "*",
15
+ "@deepseek-ai/schemastery": "*",
16
+ "z3-solver": "^5.2.0"
17
+ },
18
+ "devDependencies": {
19
+ "@types/node": "^22.0.0",
20
+ "tsx": "^4.19.0",
21
+ "typescript": "^5.6.0"
22
+ }
23
+ }
@@ -0,0 +1,45 @@
1
+ /**
2
+ * OpenFlights 骨架校验(M2 段 3,§7-1 骨架层):引擎候选集的通航性合法性校验。
3
+ * 数据源:data/openflights-skeleton.json(OpenFlights ODbL,枢纽间 168 对)。
4
+ * 语义:「查不到≠不可达」(骨架只含枢纽间),但「查到」= 候选集强证据。
5
+ * 运行:cd ts && npx tsx scripts/skeleton-check.ts HKG HKT
6
+ */
7
+
8
+ import { readFile } from 'node:fs/promises'
9
+ import { join } from 'node:path'
10
+
11
+ interface Skeleton {
12
+ meta: { source: string; note: string; hub_filter: string[] }
13
+ pairs: Record<string, string[]>
14
+ }
15
+
16
+ let cached: Skeleton | null = null
17
+
18
+ async function load(): Promise<Skeleton> {
19
+ if (!cached) {
20
+ cached = JSON.parse(await readFile(join(import.meta.dirname, '..', '..', 'data', 'openflights-skeleton.json'), 'utf-8')) as Skeleton
21
+ }
22
+ return cached
23
+ }
24
+
25
+ export async function checkConnectivity(a: string, b: string): Promise<{ connected: boolean; airlines?: string[]; evidence: string }> {
26
+ const skeleton = await load()
27
+ const key = [a.toUpperCase(), b.toUpperCase()].sort().join('-')
28
+ const airlines = skeleton.pairs[key]
29
+ if (airlines) {
30
+ return { connected: true, airlines, evidence: `[骨架:openflights] ✅ ${a}↔${b} 直飞(${airlines.length}+ 航司:${airlines.join(',')})` }
31
+ }
32
+ const inHub = (skeleton.meta.hub_filter as string[]).includes(a.toUpperCase())
33
+ && (skeleton.meta.hub_filter as string[]).includes(b.toUpperCase())
34
+ return {
35
+ connected: false,
36
+ evidence: inHub
37
+ ? `[骨架:openflights] ❌ ${a}↔${b} 枢纽间无直飞记录——引擎应将此候选降权或要求中转`
38
+ : `[骨架:openflights] ○ ${a}或${b}不在枢纽集,骨架不覆盖(不作否定结论)`,
39
+ }
40
+ }
41
+
42
+ // CLI 直跑
43
+ if (process.argv[2] && process.argv[3]) {
44
+ console.log((await checkConnectivity(process.argv[2], process.argv[3])).evidence)
45
+ }
@@ -0,0 +1,23 @@
1
+ /**
2
+ * 骨架层集成验证(§7-1 消费面):开 skeletonHub + 带 route 的段 → 结果带三值标注;
3
+ * 枢纽间否定只标注不排除——EK329(数据集滞后的新航线)必须存活。
4
+ * 运行(在 ts/ 下):npx tsx scripts/skeleton-integration-test.ts
5
+ */
6
+
7
+ import assert from 'node:assert/strict'
8
+ import { readFile } from 'node:fs/promises'
9
+ import { join } from 'node:path'
10
+ import { parseFlightPackToSpec, solveUnified } from '../src/unified.ts'
11
+
12
+ const pack = JSON.parse(await readFile(join('..', 'data', 'flights_2026.json'), 'utf-8'))
13
+ const spec = parseFlightPackToSpec(pack)
14
+ spec.budgetCny = 9000
15
+ spec.skeletonHub = true
16
+ spec.segments.forEach(s => { if (s.id === 'f5') s.route = 'SZX->DXB'; if (s.id === 'f1') s.route = 'HKG->HKT' })
17
+ const r = await solveUnified(spec)
18
+ assert.equal(r.feasible, true, '骨架否定不排除——EK329 必须仍在')
19
+ const f5 = r.legs!.find(l => l.leg === 'f5')
20
+ assert.equal(f5?.service, 'EK329', 'EK329 存活(骨架滞后容错)')
21
+ assert.ok((r.skeleton_notes ?? []).some(n => n.includes('SZX') && n.includes('❌')), `枢纽否定标注在: ${r.skeleton_notes}`)
22
+ assert.ok((r.skeleton_notes ?? []).some(n => n.includes('HKG') && n.includes('✅')), '正向标注在')
23
+ console.log(`骨架集成 OK:EK329 在枢纽否定下存活,标注=[${r.skeleton_notes?.join(' | ')}]`)
@@ -0,0 +1,44 @@
1
+ /**
2
+ * GoTry 桥接工具集(纯 Node 版,无 Python 依赖):
3
+ * - ensureStateDir: 状态目录创建
4
+ * - recordLatency: 延迟日志追加
5
+ * - readJson / writeJson: JSON 文件安全读写
6
+ *
7
+ * 历史:曾有 callFeasibilityEngine 用 spawn(.venv/bin/python) 起 Python CLI,
8
+ * 作为 TS 求解的回退路径。D-7 切轨后 unified solveChoiceSegment 是唯一求解入口,
9
+ * Python oracle 路径于 v0.0.1-rc.2 移除——npm 一键分发不再需要 Python 运行时依赖。
10
+ */
11
+
12
+ import { appendFile, mkdir, readFile, writeFile } from 'node:fs/promises'
13
+ import { join } from 'node:path'
14
+
15
+ /** 状态目录:动机画像 / wish pool 的落盘位置(红线 6:用户数据可见、可编辑、可删除) */
16
+ export async function ensureStateDir(root: string): Promise<string> {
17
+ const dir = join(root, 'gotry-state')
18
+ await mkdir(dir, { recursive: true })
19
+ return dir
20
+ }
21
+
22
+ /** JSON 安全读取:文件不存在返回 fallback,解析失败抛错 */
23
+ export async function readJson<T>(path: string, fallback: T): Promise<T> {
24
+ try {
25
+ const text = await readFile(path, 'utf-8')
26
+ return JSON.parse(text) as T
27
+ } catch (e) {
28
+ if ((e as NodeJS.ErrnoException).code === 'ENOENT') return fallback
29
+ throw e
30
+ }
31
+ }
32
+
33
+ /** JSON 原子写入 */
34
+ export async function writeJson(path: string, value: unknown): Promise<void> {
35
+ const tmp = `${path}.tmp`
36
+ await writeFile(tmp, JSON.stringify(value, null, 2), 'utf-8')
37
+ const { rename } = await import('node:fs/promises')
38
+ await rename(tmp, path)
39
+ }
40
+
41
+ /** 桥接延迟日志(追加式;T5 成本工程的度量数据源之一) */
42
+ export async function recordLatency(logPath: string, latencyMs: number, kind: string): Promise<void> {
43
+ await appendFile(logPath, JSON.stringify({ ts: new Date().toISOString(), kind, latencyMs }) + '\n')
44
+ }
@@ -0,0 +1,165 @@
1
+ /**
2
+ * Stage 1 契约层(S1,docs/stage1-top-down-design.md §2)——顶层数据与工具面契约。
3
+ *
4
+ * 自顶向下纪律:本文件是唯一权威契约。实现(mock 循环/求解器挂载/真 LLM)都向这里对齐;
5
+ * 契约变更需走设计文档升版,不随实现漂移。
6
+ * 责任铁律:LLM 只做问句组织/翻译/解释;判定与算术永远是确定性组件。
7
+ */
8
+
9
+ import type { JourneySpecTS } from './unified.ts'
10
+
11
+ // ---- TripState:会话状态契约(一切组件围绕它读写) --------------------------------
12
+
13
+ export interface CalendarState {
14
+ year: number
15
+ /** 一次断言终身使用:{"2026-07-17": "fri"}——Kimi 三轮日历混乱的解药 */
16
+ assertedWeekdays: Record<string, 'mon' | 'tue' | 'wed' | 'thu' | 'fri' | 'sat' | 'sun'>
17
+ }
18
+
19
+ export interface WorkWindowProfile {
20
+ homeTzOffsetMin: number
21
+ startMin: number
22
+ endMin: number
23
+ workdays: number[]
24
+ /** 证据:用户原话(P0 反幻觉,与动机画像同规) */
25
+ evidence: string
26
+ }
27
+
28
+ export interface TravelerProfile {
29
+ workWindow?: WorkWindowProfile
30
+ companions?: string[]
31
+ budgetTier?: 'economy' | 'comfort' | 'convenience'
32
+ /** 已订资源(航班/酒店)——Kimi 复盘:第 6 轮才被问出的关键事实 */
33
+ bookedResources?: Array<{ kind: 'flight' | 'hotel'; ref: string; window?: string }>
34
+ motivation?: { weights: Record<string, number>; evidence: string[] }
35
+ }
36
+
37
+ export interface GateOption {
38
+ label: string
39
+ tradeOff?: string
40
+ }
41
+
42
+ export interface Gate {
43
+ id: string
44
+ question: string
45
+ options: GateOption[]
46
+ /** 回答后回填 */
47
+ answer?: string
48
+ }
49
+
50
+ export interface WishEntry {
51
+ name: string
52
+ reason: string
53
+ conditions: Record<string, unknown>
54
+ addedAt?: string
55
+ }
56
+
57
+ export interface TripState {
58
+ calendar: CalendarState
59
+ profile: TravelerProfile
60
+ spec?: JourneySpecTS
61
+ solve?: SolveResult
62
+ gates: Gate[]
63
+ wishes: WishEntry[]
64
+ }
65
+
66
+ // ---- 求解结果(unified 求解器输出的契约化引用) ----------------------------------
67
+
68
+ export interface SolveResult {
69
+ feasible: boolean
70
+ money_cny?: number
71
+ legs?: Array<Record<string, unknown> & { leg: string }>
72
+ red_flags?: string[]
73
+ unsat_core?: string[]
74
+ suggestions?: Array<{ relax: string; money_cny?: number }>
75
+ work_window_exclusions?: Array<{ segment: string; option: string; reason: string }>
76
+ skeleton_notes?: string[]
77
+ verdicts?: Array<Record<string, unknown>>
78
+ recommended?: string | null
79
+ }
80
+
81
+ // ---- 工具面契约(五个;dsh 插件按此注册) -----------------------------------------
82
+
83
+ export interface Turn {
84
+ role: 'user' | 'assistant'
85
+ text: string
86
+ }
87
+
88
+ /** ADR-9:访谈由缺失字段驱动(确定性),LLM 只润色问句 */
89
+ export interface InterviewQuestion {
90
+ key: string // 对应 profile 的缺失字段,如 "workWindow" / "bookedResources"
91
+ text: string
92
+ why: string // 为什么问(Kimi 复盘:不解释的追问=审讯)
93
+ options?: GateOption[]
94
+ }
95
+
96
+ export interface GotryInterviewNextIO {
97
+ input: { state: TripState; brief?: string }
98
+ output: { questions: InterviewQuestion[]; missing: string[] }
99
+ }
100
+
101
+ export interface SpecAssumption {
102
+ field: string
103
+ value: unknown
104
+ source: 'user-verbatim' | 'inferred' | 'default' // inferred 必须在渲染时声明
105
+ }
106
+
107
+ export interface GotrySpecExtractIO {
108
+ input: { history: Turn[]; state: TripState }
109
+ output: { spec: JourneySpecTS; assumptions: SpecAssumption[]; calendarConflicts?: string[] }
110
+ }
111
+
112
+ export interface GotrySolveIO {
113
+ input: { spec: JourneySpecTS }
114
+ output: { result: SolveResult }
115
+ }
116
+
117
+ export interface GotryRenderIO {
118
+ input: { state: TripState }
119
+ output: { replyMd: string; cardsMd: string[]; gates: Gate[] }
120
+ }
121
+
122
+ export interface GotryWishPoolIO {
123
+ input: { entry: WishEntry }
124
+ output: { added: boolean; total: number }
125
+ }
126
+
127
+ /** 工具注册表:名字 → IO 类型(dsh 插件按此生成 parameters/output schema) */
128
+ export const TOOL_CONTRACTS = {
129
+ 'gotry_interview_next': {} as GotryInterviewNextIO,
130
+ 'gotry_spec_extract': {} as GotrySpecExtractIO,
131
+ 'gotry_solve': {} as GotrySolveIO,
132
+ 'gotry_render': {} as GotryRenderIO,
133
+ 'gotry_wish_pool_add': {} as GotryWishPoolIO,
134
+ } as const
135
+
136
+ export type ToolName = keyof typeof TOOL_CONTRACTS
137
+
138
+ // ---- wire schema(dsh 注册用的 JSON Schema 面;type:'json' 根 + 关键字段文档) ----
139
+
140
+ export const TRIP_STATE_JSON_SCHEMA = {
141
+ type: 'object',
142
+ additionalProperties: false,
143
+ properties: {
144
+ calendar: {
145
+ type: 'object', additionalProperties: true,
146
+ description: '年份与已断言的星期映射;一次断言终身使用,冲突必须显式指出',
147
+ },
148
+ profile: {
149
+ type: 'object', additionalProperties: true,
150
+ description: 'workWindow(带 evidence)/companions/budgetTier/bookedResources/motivation',
151
+ },
152
+ spec: { type: 'json', description: '统一行程模型 JourneySpec(见 unified.ts)' },
153
+ solve: { type: 'json', description: '求解结果(锚点/排除/红旗/建议)' },
154
+ gates: {
155
+ type: 'array',
156
+ description: '待决问题,只能是选择题',
157
+ items: {
158
+ type: 'object', additionalProperties: true,
159
+ properties: { id: { type: 'string' }, question: { type: 'string' } },
160
+ },
161
+ },
162
+ wishes: { type: 'array', description: '「下一次出发」清单', items: { type: 'object', additionalProperties: true } },
163
+ },
164
+ required: ['calendar', 'profile', 'gates', 'wishes'],
165
+ } as const