@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.
@@ -0,0 +1,133 @@
1
+ /**
2
+ * 预订 saga 状态机(booking_saga_fsm.v1)——issue #17 采纳三点的具名化落点。
3
+ *
4
+ * 立场(docs/booking-saga-fsm.md / ADR-17):不引入编排框架;预订 saga 的状态字母表与边表
5
+ * 就是账本 pending_writes(status CHECK:pending|confirmed|compensated)的显式化。
6
+ * 本模块是纯函数词汇层,零依赖、零行为、不接线任何 seam——M5 拍板 WriteGate 生产化时
7
+ * 以此为唯一状态推进词汇(具名化 = 状态与边不再散落在 SQL 字符串里)。
8
+ *
9
+ * 物理对账目标(state-ledger.ts,TS-4 基座):
10
+ * - propose = requestPendingWrite(∅ → pending,L2 只登记不执行;idem_key UNIQUE,重复提议 no-op)
11
+ * - confirm = confirmPendingWrite(pending → confirmed,L3 具名 seam 确认,携 receipt;无 receipt 则视为缺省空串)
12
+ * - compensate = compensatePendingWrite(pending|confirmed → compensated,吸收态,无出边)
13
+ * - 审计事件 kind:write.pending / write.confirmed / write.compensated
14
+ */
15
+
16
+ export const BOOKING_SAGA_SCHEMA = 'booking_saga_fsm.v1'
17
+
18
+ export type SagaStatus = 'pending' | 'confirmed' | 'compensated'
19
+ export type SagaOrigin = 'none' | SagaStatus
20
+ export type SagaTrigger = 'propose' | 'confirm' | 'compensate'
21
+ export type SagaLedgerEvent = 'write.pending' | 'write.confirmed' | 'write.compensated'
22
+
23
+ /** 状态字母表(与 pending_writes.status CHECK 约束逐字一致) */
24
+ export const SAGA_STATUSES = ['pending', 'confirmed', 'compensated'] as const satisfies readonly SagaStatus[]
25
+
26
+ export interface SagaEdge {
27
+ from: SagaOrigin
28
+ trigger: SagaTrigger
29
+ to: SagaStatus
30
+ event: SagaLedgerEvent
31
+ /** 边的守卫说明;物理执行在账本事务内(UNIQUE 守卫/CHECK),此处为语义面 */
32
+ guard: string
33
+ }
34
+
35
+ /**
36
+ * 边表(预订流程 FSM 的全部合法转移):
37
+ * ∅ --propose--> pending --confirm--> confirmed
38
+ * │ │
39
+ * └---(任何状态) --compensate--> compensated(吸收态,无出边)
40
+ */
41
+ export const SAGA_EDGES: ReadonlyArray<SagaEdge> = [
42
+ { from: 'none', trigger: 'propose', to: 'pending', event: 'write.pending', guard: 'L2 只登记不执行;idem_key UNIQUE(重复提议 no-op)' },
43
+ { from: 'pending', trigger: 'confirm', to: 'confirmed', event: 'write.confirmed', guard: 'L3 具名 seam 确认,必携 receipt;已确认不可再确认' },
44
+ { from: 'pending', trigger: 'compensate', to: 'compensated', event: 'write.compensated', guard: '外部写未发生,取消即终态(无补偿动作)' },
45
+ { from: 'confirmed', trigger: 'compensate', to: 'compensated', event: 'write.compensated', guard: '已发生副作用的 saga 补偿(退改),receipt 保留(COALESCE)' },
46
+ ]
47
+
48
+ export type SagaRejection =
49
+ | 'missing-subject'
50
+ | 'idem-exists'
51
+ | 'already-confirmed'
52
+ | 'absorbed-compensated'
53
+ | 'already-compensated'
54
+
55
+ /** 非边组合的拒绝理由(结构化,拒绝即不动——与账本 changes=0 同义) */
56
+ export const SAGA_REJECTIONS: ReadonlyArray<{ from: SagaOrigin; trigger: SagaTrigger; reason: SagaRejection }> = [
57
+ { from: 'none', trigger: 'confirm', reason: 'missing-subject' },
58
+ { from: 'none', trigger: 'compensate', reason: 'missing-subject' },
59
+ { from: 'pending', trigger: 'propose', reason: 'idem-exists' },
60
+ { from: 'confirmed', trigger: 'propose', reason: 'idem-exists' },
61
+ { from: 'confirmed', trigger: 'confirm', reason: 'already-confirmed' },
62
+ { from: 'compensated', trigger: 'propose', reason: 'idem-exists' },
63
+ { from: 'compensated', trigger: 'confirm', reason: 'absorbed-compensated' },
64
+ { from: 'compensated', trigger: 'compensate', reason: 'already-compensated' },
65
+ ]
66
+
67
+ export type SagaVerdict =
68
+ | { ok: true; from: SagaOrigin; trigger: SagaTrigger; to: SagaStatus; event: SagaLedgerEvent; guard: string; reason: null }
69
+ | { ok: false; from: SagaOrigin; trigger: SagaTrigger; to: null; event: null; guard: null; reason: SagaRejection }
70
+
71
+ /** 触发解析:唯一归宿或结构化拒绝(预订 seam 的状态推进只许走这张表的边) */
72
+ export function resolveSagaTrigger(origin: SagaOrigin, trigger: SagaTrigger): SagaVerdict {
73
+ const edge = SAGA_EDGES.find(e => e.from === origin && e.trigger === trigger)
74
+ if (edge) return { ok: true, from: origin, trigger, to: edge.to, event: edge.event, guard: edge.guard, reason: null }
75
+ const rejection = SAGA_REJECTIONS.find(e => e.from === origin && e.trigger === trigger)
76
+ return { ok: false, from: origin, trigger, to: null, event: null, guard: null, reason: rejection!.reason }
77
+ }
78
+
79
+ /** 触发器 → 账本事件 kind 的唯一映射(写入侧命名不再各处硬编码) */
80
+ export const SAGA_TRIGGER_EVENT: Record<SagaTrigger, SagaLedgerEvent> = {
81
+ propose: 'write.pending',
82
+ confirm: 'write.confirmed',
83
+ compensate: 'write.compensated',
84
+ }
85
+
86
+ /** 账本事件 kind → 触发器(读取/审计侧);非 saga 事件返回 null */
87
+ export function triggerOfEventKind(kind: string): SagaTrigger | null {
88
+ if (kind === 'write.pending') return 'propose'
89
+ if (kind === 'write.confirmed') return 'confirm'
90
+ if (kind === 'write.compensated') return 'compensate'
91
+ return null
92
+ }
93
+
94
+ export interface SagaAuditEvent {
95
+ seq?: number
96
+ kind: string
97
+ /** write.confirmed 携带的外部回执(payload.receipt) */
98
+ receipt?: string | null
99
+ }
100
+
101
+ /**
102
+ * 审计链校验(单 idem_key 的 write.* 事件序列):必须恰好走出边表的一条合法路径。
103
+ * - 链以 write.pending 开头;
104
+ * - confirm/compensate 至多各一次;
105
+ * - compensated 之后不允许任何 saga 事件(吸收态);
106
+ * - write.confirmed 的 receipt 为空(缺省/空串)即违例——L3 具名确认必携外部回执;
107
+ * - 非 write.* 事件(其他域审计)穿插不计。
108
+ * 返回违例清单(空数组 = 链合法)。
109
+ */
110
+ export function sagaTraceViolations(events: ReadonlyArray<SagaAuditEvent>): string[] {
111
+ const violations: string[] = []
112
+ let stage: SagaOrigin = 'none'
113
+ for (const ev of events) {
114
+ const trigger = triggerOfEventKind(ev.kind)
115
+ if (!trigger) continue
116
+ const verdict = resolveSagaTrigger(stage, trigger)
117
+ const at = `seq=${ev.seq ?? '?'} ${ev.kind}`
118
+ if (!verdict.ok) {
119
+ violations.push(`${at}: 非法(${stage} --${trigger}--> 拒绝 ${verdict.reason})`)
120
+ continue
121
+ }
122
+ stage = verdict.to
123
+ if (trigger === 'confirm' && (ev.receipt === undefined || ev.receipt === null || ev.receipt === '')) {
124
+ violations.push(`${at}: receipt 为空(L3 具名确认必携外部回执)`)
125
+ }
126
+ }
127
+ return violations
128
+ }
129
+
130
+ /** 完整合法路径集合(文档/测试共用):∅→pending→confirmed→compensated 与其前缀 */
131
+ export function sagaLegalPaths(): ReadonlyArray<ReadonlyArray<SagaTrigger>> {
132
+ return [['propose'], ['propose', 'compensate'], ['propose', 'confirm'], ['propose', 'confirm', 'compensate']]
133
+ }
package/ts/src/dsh-llm.ts CHANGED
@@ -18,7 +18,25 @@ import { buildSlotSystem, flagExpiredSlots, normalizeExtraction, type TravelSlot
18
18
  const model = () => process.env['LLM_MODEL'] ?? process.env['DEEPSEEK_MODEL'] ?? 'MiniMax-M2'
19
19
  const base = () => (process.env['LLM_BASE_URL'] ?? process.env['DEEPSEEK_BASE_URL'] ?? 'https://api.minimax.io/v1').replace(/\/$/, '')
20
20
 
21
- async function chat(messages: Array<{ role: 'system' | 'user' | 'assistant'; content: string }>, json: boolean): Promise<string> {
21
+ /**
22
+ * Token 用量累计器(OpenAI 兼容 usage 映射)。nightly real-LLM 成本证据(Issue #22
23
+ * `gotry_m3_nightly_run_v1.cost_usd`)的唯一测量来源——成本不允许凭空断言,只允许
24
+ * 这里实测出的 tokens × 封存价表换算;responsesMissingUsage > 0 时成本不可证,调用方必须 fail-closed。
25
+ */
26
+ export interface LlmUsageTracker {
27
+ calls: number
28
+ inputTokens: number
29
+ outputTokens: number
30
+ inputCacheHitTokens: number
31
+ inputCacheMissTokens: number
32
+ responsesMissingUsage: number
33
+ }
34
+
35
+ function emptyUsage(): LlmUsageTracker {
36
+ return { calls: 0, inputTokens: 0, outputTokens: 0, inputCacheHitTokens: 0, inputCacheMissTokens: 0, responsesMissingUsage: 0 }
37
+ }
38
+
39
+ async function chat(messages: Array<{ role: 'system' | 'user' | 'assistant'; content: string }>, json: boolean, usage?: LlmUsageTracker): Promise<string> {
22
40
  const key = process.env['LLM_API_KEY'] ?? process.env['DEEPSEEK_API_KEY']
23
41
  if (!key) throw new Error('LLM_API_KEY 未设置(兼容 DEEPSEEK_API_KEY 别名)——真 LLM 路径不可用,请回退 mock(ADR-8)')
24
42
  const res = await fetch(`${base()}/chat/completions`, {
@@ -32,7 +50,20 @@ async function chat(messages: Array<{ role: 'system' | 'user' | 'assistant'; con
32
50
  }),
33
51
  })
34
52
  if (!res.ok) throw new Error(`llm ${res.status}: ${(await res.text()).slice(0, 300)}`)
35
- const data = await res.json() as { choices: Array<{ message: { content: string } }> }
53
+ const data = await res.json() as { choices: Array<{ message: { content: string } }>; usage?: { prompt_tokens?: number; completion_tokens?: number; prompt_cache_hit_tokens?: number; prompt_cache_miss_tokens?: number } }
54
+ if (usage) {
55
+ usage.calls += 1
56
+ const u = data.usage
57
+ if (u && Number.isFinite(u.prompt_tokens) && Number.isFinite(u.completion_tokens)) {
58
+ usage.inputTokens += u.prompt_tokens ?? 0
59
+ usage.outputTokens += u.completion_tokens ?? 0
60
+ usage.inputCacheHitTokens += u.prompt_cache_hit_tokens ?? 0
61
+ // 无缓存明细的 provider:全部输入按 miss 记(随后按 peak miss 价换算,只高不低)
62
+ usage.inputCacheMissTokens += u.prompt_cache_miss_tokens ?? (u.prompt_tokens ?? 0)
63
+ } else {
64
+ usage.responsesMissingUsage += 1
65
+ }
66
+ }
36
67
  const raw = data.choices[0]?.message?.content ?? ''
37
68
  // 推理模型(MiniMax-M2 等):剥 <think> 块,只留正文;未闭合时留全文由上层容错
38
69
  return raw.replace(/<think>[\s\S]*?<\/think>/g, '').trim() || raw
@@ -62,8 +93,9 @@ const SKELETON_SYSTEM = `你是行程骨架抽取器。从对话中抽取行程
62
93
  规则:每个跨城移动一段;锚点只放用户明说或必然的(如"当天到"→arriveByMin 23:59=1439);时刻用当日分钟。
63
94
  scenario 判定:「洱海/大理/千岛湖/太湖+选目的地」→erhai(候选集);「普吉/workation/远程办公+多城链」→workation(五段链);「云南/大理丽江」→yunnan;不确定→generic。只输出 JSON。`
64
95
 
65
- export function createOpenAICompatLlm(flightPackPath?: string, clock: () => Date = () => new Date()): LlmPort {
96
+ export function createOpenAICompatLlm(flightPackPath?: string, clock: () => Date = () => new Date()): LlmPort & { usage: LlmUsageTracker } {
66
97
  const pack = flightPackPath
98
+ const usage = emptyUsage()
67
99
  const historyText = (h: Turn[]) => h.map(t => `${t.role === 'user' ? '用户' : '助手'}: ${t.text}`).join('\n')
68
100
  // 时间锚点卡:每条抽取链路都带上「今天」——legacy 路径此前无时间注入,
69
101
  // 过期/相对日期语义全靠它(算术在 time-anchor 层,LLM 只查卡)。
@@ -73,6 +105,7 @@ export function createOpenAICompatLlm(flightPackPath?: string, clock: () => Date
73
105
  const out = await chat(
74
106
  [{ role: 'system', content: FACTS_SYSTEM }, { role: 'user', content: `${anchorContext()}\n\n${historyText(history)}` }],
75
107
  true,
108
+ usage,
76
109
  )
77
110
  const obj = parseJsonBlock(out)
78
111
  if (!obj) return { assumptions: [] }
@@ -87,6 +120,7 @@ export function createOpenAICompatLlm(flightPackPath?: string, clock: () => Date
87
120
  const out = await chat(
88
121
  [{ role: 'system', content: SKELETON_SYSTEM }, { role: 'user', content: `${context}\n\n${historyText(history)}` }],
89
122
  true,
123
+ usage,
90
124
  )
91
125
  const skeleton = parseJsonBlock(out)
92
126
  if (!skeleton || !Array.isArray(skeleton['segments']) || (skeleton['segments'] as unknown[]).length === 0) return null
@@ -136,6 +170,7 @@ export function createOpenAICompatLlm(flightPackPath?: string, clock: () => Date
136
170
  const out = await chat(
137
171
  [{ role: 'system', content: buildSlotSystem(anchor) }, { role: 'user', content: historyText(history) }],
138
172
  true,
173
+ usage,
139
174
  )
140
175
  const obj = parseJsonBlock(out)
141
176
  if (!obj) return null
@@ -148,8 +183,10 @@ export function createOpenAICompatLlm(flightPackPath?: string, clock: () => Date
148
183
  [{ role: 'system', content: '把旅行规划的追问润色得更自然,保留全部信息(含为什么问),一两句话,不要加表情。' },
149
184
  { role: 'user', content: `【${q.key}】${q.text}(为什么问:${q.why})` }],
150
185
  false,
186
+ usage,
151
187
  )
152
188
  return out.trim() || `【${q.key}】${q.text}`
153
189
  },
190
+ usage,
154
191
  }
155
192
  }
package/ts/src/index.ts CHANGED
@@ -22,7 +22,6 @@ import { ensureStateDir, recordLatency } from './bridge.ts'
22
22
  import { segmentsFromCandidate, solveChoiceSegment } from './unified.ts'
23
23
  import { checkConnectivity } from '../scripts/skeleton-check.ts'
24
24
  import { parseCandidate, parseRequest } from './model.ts'
25
- import { searchHotels as hbcliSearchHotels } from '../capabilities/hbcli.ts'
26
25
  import { installProcessGuards, guardToolExecute } from '../capabilities/incident-log.ts'
27
26
  import { interpretArgs, type GotryObservation } from './tool-packet.ts'
28
27
  import { projectUtility } from './memory-utility.ts'
@@ -31,15 +30,14 @@ import { resolveTimelineDate } from './travel-timeline.ts'
31
30
  import { ensureLedger, readCompanionsWithFallback, readMotivationWithFallback, readTripsWithFallback, readWishPoolWithFallback } from './state-ledger.ts'
32
31
  import { buildTimeAnchor } from './time-anchor.ts'
33
32
  import { resolveSlotDate } from './slot-spec.ts'
34
- import { geocodePlace, getForecast, getClimate, wmoLabel } from '../capabilities/weather.ts'
35
- import { verifyFlight } from '../capabilities/opensky.ts'
33
+ import { wmoLabel } from '../capabilities/weather.ts'
36
34
  import { anythingSearch } from '../capabilities/anything.ts'
37
35
  import { readUrl, reach, reachStatus } from '../capabilities/agent-reach.ts'
38
36
  import { videoSubtitle, githubSearch } from '../capabilities/agent-reach-deep.ts'
39
- import { flyaiSearch } from '../capabilities/flyai.ts'
40
- import { sessionFlightSearch } from '../capabilities/session-search.ts'
41
37
  import { sessionLogin } from '../capabilities/session-login.ts'
42
38
  import { createConsentGate, approvalFromContext } from '../capabilities/session-consent.ts'
39
+ import { listArtifacts, readArtifact } from '../capabilities/artifacts.ts'
40
+ import { interpretEffect, declinedObservation } from '../capabilities/effect.ts'
43
41
 
44
42
  export const name = 'gotry-tools'
45
43
  export const inject = ['tools', 'systemPrompt']
@@ -493,10 +491,14 @@ export function apply(ctx: Context, config: Config): void {
493
491
  if (r.raw !== r.date) dateNotes.push(`slot-resolved: ${r.raw} → ${r.date}`)
494
492
  return r.date
495
493
  }
496
- const resp = await hbcliSearchHotels(
497
- { destination: q.destination, checkIn: resolveDate(q.checkIn), checkOut: resolveDate(q.checkOut), adults: q.adults },
498
- { hbcliBin: config.hbcliBin, timeoutMs: config.timeoutMs, fallbackPath },
499
- )
494
+ // 效应解译层(ADR-18):渠道选择/韧性(重试/熔断)在解译器,result 原样透传——
495
+ // 断路拒绝时返回平铺失败面(不发起查询,不伪装成 miss)
496
+ const itp = await interpretEffect({
497
+ effect: 'HBCLI_HOTEL_SEARCH',
498
+ params: { destination: q.destination, checkIn: resolveDate(q.checkIn), checkOut: resolveDate(q.checkOut), adults: q.adults, hbcliBin: config.hbcliBin, timeoutMs: config.timeoutMs, fallbackPath },
499
+ })
500
+ if (!itp.result) return declinedObservation('HBCLI_HOTEL_SEARCH', itp.trace)
501
+ const resp = itp.result
500
502
  const dir = await ensureStateDir(config.stateRoot)
501
503
  const isLive = resp.via === 'hbcli-realtime'
502
504
  const evidence = isLive ? resp.evidence : '[静态包:估算]'
@@ -516,11 +518,18 @@ export function apply(ctx: Context, config: Config): void {
516
518
  },
517
519
  presentCall: args => ({ card: 'generic', title: `酒店搜索:${String((args.query as { destination?: string })?.destination ?? '')}`, kind: 'search', rawInput: args.query }),
518
520
  presentResult: (_args, value) => {
519
- const r = value as { hotels?: unknown[]; via?: string; destination?: string; summary?: string }
520
- const n = Array.isArray(r.hotels) ? r.hotels.length : 0
521
+ const r = value as { hotels?: unknown; via?: string; destination?: string; summary?: string }
522
+ const h = r.hotels
523
+ const liveCount = Array.isArray(h) ? h.length : 0
524
+ // 静态包是 {meta, stays} 对象而非数组(issue #24:此前计数恒 0 → UI 显示「无结果」)
525
+ const stays = !Array.isArray(h) && h && typeof h === 'object' ? (h as { stays?: unknown[] }).stays : undefined
526
+ const staticCount = Array.isArray(stays) ? stays.length : 0
527
+ const tag = r.via === 'hbcli-realtime'
528
+ ? (liveCount ? `实时 ${liveCount} 家` : '实时')
529
+ : staticCount ? `静态包 ${staticCount} 块` : (liveCount ? `${liveCount} 家` : '无结果')
521
530
  return {
522
531
  card: 'generic',
523
- title: `酒店:${r.destination ?? ''} ${n ? `${n} 家(${r.via === 'hbcli-realtime' ? '实时' : '静态包'})` : '无结果'}`,
532
+ title: `酒店:${r.destination ?? ''} ${tag}`,
524
533
  content: [{ type: 'text', text: String(r.summary ?? '') }],
525
534
  }
526
535
  },
@@ -576,7 +585,9 @@ export function apply(ctx: Context, config: Config): void {
576
585
  let placeLabel = q.place ?? `${q.lat},${q.lng}`
577
586
  if (lat === undefined || lng === undefined) {
578
587
  if (!q.place) throw new Error('gotry_weather_check requires place name or lat/lng')
579
- const geo = await geocodePlace(q.place)
588
+ const geoItp = await interpretEffect({ effect: 'WEATHER_GEOCODE', params: { name: q.place } })
589
+ if (!geoItp.result) return declinedObservation('WEATHER_GEOCODE', geoItp.trace)
590
+ const geo = geoItp.result
580
591
  if (!geo.ok || geo.results.length === 0) {
581
592
  return JSON.parse(JSON.stringify({ ok: false, summary: `地点「${q.place}」地理编码失败:${geo.error ?? '无结果'}`, evidence: geo.evidence })) as Record<string, never>
582
593
  }
@@ -585,9 +596,11 @@ export function apply(ctx: Context, config: Config): void {
585
596
  placeLabel = `${hit.name}(${hit.admin1 ?? hit.country ?? ''})`
586
597
  }
587
598
  const isClimate = q.mode === 'climate' || (q.month !== undefined && q.mode !== 'forecast')
588
- const r = isClimate
589
- ? await getClimate({ latitude: lat, longitude: lng }, q.month ?? new Date().getMonth() + 1)
590
- : await getForecast({ latitude: lat, longitude: lng }, { days: q.days })
599
+ const wxItp = isClimate
600
+ ? await interpretEffect({ effect: 'WEATHER_CLIMATE', params: { latitude: lat, longitude: lng, month: q.month ?? new Date().getMonth() + 1 } })
601
+ : await interpretEffect({ effect: 'WEATHER_FORECAST', params: { latitude: lat, longitude: lng, days: q.days } })
602
+ if (!wxItp.result) return declinedObservation(isClimate ? 'WEATHER_CLIMATE' : 'WEATHER_FORECAST', wxItp.trace)
603
+ const r = wxItp.result
591
604
  const dir = await ensureStateDir(config.stateRoot)
592
605
  await recordLatency(join(dir, 'bridge-latency.jsonl'), Date.now() - started, `weather:${r.via}`).catch(() => {})
593
606
  const dailyLines = (r.daily ?? []).slice(0, 7).map(d =>
@@ -642,7 +655,9 @@ export function apply(ctx: Context, config: Config): void {
642
655
  if (!q.callsign) {
643
656
  return JSON.parse(JSON.stringify({ verdict: 'unavailable', evidence: '[校验不可用:无 callsign]', summary: 'callsign 必填' })) as Record<string, never>
644
657
  }
645
- const r = await verifyFlight({ callsign: q.callsign, airport: q.airport, timeoutMs: q.timeoutMs })
658
+ const itp = await interpretEffect({ effect: 'OPENSKY_FLIGHT_VERIFY', params: { callsign: q.callsign, airport: q.airport, timeoutMs: q.timeoutMs } })
659
+ if (!itp.result) return declinedObservation('OPENSKY_FLIGHT_VERIFY', itp.trace)
660
+ const r = itp.result
646
661
  const dir = await ensureStateDir(config.stateRoot)
647
662
  await recordLatency(join(dir, 'bridge-latency.jsonl'), Date.now() - started, `flight_verify:${r.via}`).catch(() => {})
648
663
  const summary = r.verdict === 'observed'
@@ -694,7 +709,9 @@ export function apply(ctx: Context, config: Config): void {
694
709
  return { ok: false, summary: 'checkOut 需 YYYY-MM-DD(与 checkIn 成对)' } as const
695
710
  }
696
711
  }
697
- const r = await flyaiSearch({ kind: 'hotel', destName: dest, checkInDate: q.checkIn, checkOutDate: q.checkOut, keyWords: q.keyWords })
712
+ const itp = await interpretEffect({ effect: 'FLYAI_SEARCH', params: { kind: 'hotel', destName: dest, checkInDate: q.checkIn, checkOutDate: q.checkOut, keyWords: q.keyWords } })
713
+ if (!itp.result) return declinedObservation('FLYAI_SEARCH', itp.trace)
714
+ const r = itp.result
698
715
  const top = (r.hotels ?? []).slice(0, 8).map(o => `${o.name}${o.star ? `(${o.star})` : ''} ${o.priceRaw ?? '价待询'}${o.poi ? ` · ${o.poi}` : ''}`)
699
716
  const summary = r.verdict === 'hit'
700
717
  ? `${dest} 酒店(飞猪官方只读)前 ${top.length} 家(价格多为打码,真实价以 jumpUrl 为准):\n${top.join('\n')}\n${r.evidence}`
@@ -717,11 +734,18 @@ export function apply(ctx: Context, config: Config): void {
717
734
  + `多为用户时间表达未带年份所致——向用户确认年份(或按未来最近的同月日修正)后再查。`,
718
735
  })) as Record<string, never>
719
736
  }
720
- const r = await flyaiSearch({ kind, origin: q.from, destination: q.to, depDate: q.date })
737
+ const itp = await interpretEffect({ effect: 'FLYAI_SEARCH', params: { kind, origin: q.from, destination: q.to, depDate: q.date } })
738
+ if (!itp.result) return declinedObservation('FLYAI_SEARCH', itp.trace)
739
+ const r = itp.result
721
740
  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}`)
741
+ // issue #24:miss(上游正常返回 0 条)与 error(限流/网络)分开陈述,不再混写「无结果或失败」
742
+ // (过去日期已被上方预校验拦下,此处不会出现过期查询)
743
+ const label = kind === 'flight' ? '机票' : '火车票'
722
744
  const summary = r.verdict === 'hit'
723
- ? `${q.from}→${q.to} ${q.date} ${kind === 'flight' ? '机票' : '火车票'}(飞猪官方只读)前 ${top.length} 条:\n${top.join('\n')}\n${r.evidence}`
724
- : `${q.from}→${q.to} ${q.date} 无结果或失败:${r.error ?? 'miss'} ${r.evidence}`
745
+ ? `${q.from}→${q.to} ${q.date} ${label}(飞猪官方只读)前 ${top.length} 条:\n${top.join('\n')}\n${r.evidence}`
746
+ : r.verdict === 'miss'
747
+ ? `${q.from}→${q.to} ${q.date} ${label}官方通道正常返回 0 条(常见原因:航线未开放/当日售罄)。${r.evidence}`
748
+ : `${q.from}→${q.to} ${q.date} ${label}检索失败(可能限流/网络):${r.error ?? ''} ${r.evidence}`
725
749
  return JSON.parse(JSON.stringify({ ...r, kind, summary })) as Record<string, never>
726
750
  },
727
751
  presentCall: args => ({ card: 'generic', title: `官方检索:${String((args.query as { kind?: string })?.kind ?? 'flight')}`, kind: 'fetch', rawInput: args.query }),
@@ -783,11 +807,18 @@ export function apply(ctx: Context, config: Config): void {
783
807
  if (!q.from || !q.to || !q.date) {
784
808
  return { ok: false, summary: '需要 from/to(中文城市名,词表内)与 date(YYYY-MM-DD)' } as const
785
809
  }
786
- const r = await sessionFlightSearch({
787
- from: q.from, to: q.to, date: q.date,
788
- // ADR-15 收尾:ReadGuard 审计在生产工具路径同样落盘(此前仅测试传隔离 stateRoot 才有 JSONL)
789
- auditPath: join(config.stateRoot ?? '.', 'gotry-state', 'session-incidents.jsonl'),
810
+ // 效应解译层(ADR-18):SESSION 通道策略=永不重试/不熔断,节律闸在渠道内;
811
+ // 解译器只做分发与证据拼装,verdict 语义(risk needs-login/challenged)原样透传
812
+ const itp = await interpretEffect({
813
+ effect: 'SESSION_FLIGHT_SEARCH',
814
+ params: {
815
+ from: q.from, to: q.to, date: q.date,
816
+ // ADR-15 收尾:ReadGuard 审计在生产工具路径同样落盘(此前仅测试传隔离 stateRoot 才有 JSONL)
817
+ auditPath: join(config.stateRoot ?? '.', 'gotry-state', 'session-incidents.jsonl'),
818
+ },
790
819
  })
820
+ if (!itp.result) return declinedObservation('SESSION_FLIGHT_SEARCH', itp.trace)
821
+ const r = itp.result
791
822
  const top = (r.options ?? []).slice(0, 8).map(o => `${o.flightNo} ${o.airline} ${o.depDateTime.slice(11, 16)}→${o.arrDateTime.slice(11, 16)} ¥${o.price}`)
792
823
  const summary = r.verdict === 'hit'
793
824
  ? `${q.from}→${q.to} ${q.date} 会话检索(携程,用户本人登录态)前 ${top.length} 条:\n${top.join('\n')}\n${r.evidence}`
@@ -1061,4 +1092,93 @@ export function apply(ctx: Context, config: Config): void {
1061
1092
  }
1062
1093
  },
1063
1094
  }))
1095
+
1096
+ // ---- 产物面(issue #25):agent 生成的文件可在 dsh 内发现与阅读,不再只是本地文件名 ----
1097
+
1098
+ registerGuarded(defineTool({
1099
+ name: 'gotry_artifacts_list',
1100
+ description:
1101
+ 'List GoTry artifacts — deep-planning deliverables (async runs from the state ledger) plus agent-written markdown files ' +
1102
+ 'in the working directory (trip plans etc.). READ-ONLY discovery. ' +
1103
+ 'Use when the user asks to see/open/revisit a previously generated artifact ' +
1104
+ '(「看看刚才生成的行程」「上次的规划在哪」「打开那个 md」) — list first, then read with gotry_artifacts_read.',
1105
+ parameters: {
1106
+ query: {
1107
+ type: 'json',
1108
+ required: true,
1109
+ description: '{ limit?: 20 }',
1110
+ },
1111
+ },
1112
+ output: {
1113
+ schema: { type: 'json' },
1114
+ render: (_args, value) => [{ type: 'text', text: String((value as { summary?: string }).summary ?? JSON.stringify(value).slice(0, 600)) }],
1115
+ },
1116
+ async execute(args: { query: unknown }, _exec: unknown) {
1117
+ const q = unwrapQuery<{ limit?: number }>(args, 'limit')
1118
+ const r = await listArtifacts({ stateRoot: config.stateRoot ?? '.', limit: q.limit })
1119
+ const lines = r.artifacts.map(a =>
1120
+ `- [${a.source}] ${a.title}${a.status ? `(${a.status})` : ''} — ${a.path}${a.updated ? ` @ ${a.updated.slice(0, 16).replace('T', ' ')}` : ''}`)
1121
+ const summary = r.artifacts.length
1122
+ ? `在册产物 ${r.artifacts.length}/${r.total} 项${r.truncated ? '(截断,可加 limit)' : ''}:\n${lines.join('\n')}`
1123
+ : '无在册产物(异步深度规划交付与工作目录 md 文件都会出现在这里)'
1124
+ return JSON.parse(JSON.stringify({ ok: true, artifacts: r.artifacts, total: r.total, truncated: r.truncated, summary })) as Record<string, never>
1125
+ },
1126
+ presentCall: () => ({ card: 'generic', title: '列出产物', kind: 'search' }),
1127
+ presentResult: (_args, value) => {
1128
+ const r = value as { summary?: string; total?: number }
1129
+ return {
1130
+ card: 'generic',
1131
+ title: `产物:${r.total ?? 0} 项在册`,
1132
+ content: [{ type: 'text', text: String(r.summary ?? '') }],
1133
+ }
1134
+ },
1135
+ }))
1136
+
1137
+ registerGuarded(defineTool({
1138
+ name: 'gotry_artifacts_read',
1139
+ description:
1140
+ 'Read one GoTry artifact as a line-numbered file view rendered directly in the chat UI. ' +
1141
+ 'Input: the path from gotry_artifacts_list, or a bare async ticket id (e.g. dp-xxxx). ' +
1142
+ 'Optional offset (1-based) / limit window for paging large files. ' +
1143
+ 'READ-ONLY; text artifacts only (md/txt/json/jsonl/csv/log/yaml).',
1144
+ parameters: {
1145
+ query: {
1146
+ type: 'json',
1147
+ required: true,
1148
+ description: '{ path: "<list 返回的路径或工单 id>", offset?: 1, limit?: 400 }',
1149
+ },
1150
+ },
1151
+ output: {
1152
+ schema: { type: 'json' },
1153
+ render: (_args, value) => [{ type: 'text', text: String((value as { content?: string }).content ?? JSON.stringify(value).slice(0, 600)) }],
1154
+ },
1155
+ async execute(args: { query: unknown }, _exec: unknown) {
1156
+ const q = unwrapQuery<{ path?: string; offset?: number; limit?: number }>(args, 'path')
1157
+ if (!q.path) return JSON.parse(JSON.stringify({ ok: false, error: 'path 必填(来自 gotry_artifacts_list)' })) as Record<string, never>
1158
+ const r = await readArtifact({ stateRoot: config.stateRoot ?? '.', path: q.path, offset: q.offset, limit: q.limit })
1159
+ if (!r.ok) return JSON.parse(JSON.stringify(r)) as Record<string, never>
1160
+ return JSON.parse(JSON.stringify({
1161
+ ...r,
1162
+ summary: `${r.path}(${r.totalLines} 行)第 ${r.offset}-${r.offset + r.lines.length - 1} 行${r.windowed ? `(共 ${r.totalLines} 行,可翻页)` : ''}`,
1163
+ })) as Record<string, never>
1164
+ },
1165
+ presentCall: args => ({ card: 'generic', title: `读产物:${String((args.query as { path?: string })?.path ?? '')}`, kind: 'read', rawInput: args.query }),
1166
+ presentResult: (_args, value) => {
1167
+ const r = value as unknown as { ok?: boolean; path?: string; offset?: number; lines?: Array<{ number: number; text: string }>; totalLines?: number; lang?: string; content?: string; error?: string }
1168
+ if (!r.ok) {
1169
+ return { card: 'generic', title: '读产物失败', content: [{ type: 'text', text: String(r.error ?? '') }] }
1170
+ }
1171
+ // dsh read 卡:UI 渲染为行号文件视图(issue #25 的「插件能力查看 artifacts」落点)
1172
+ return {
1173
+ card: 'read' as const,
1174
+ title: r.path?.split('/').pop() ?? r.path ?? '',
1175
+ path: r.path ?? '',
1176
+ offset: r.offset ?? 1,
1177
+ lines: r.lines ?? [],
1178
+ totalLines: r.totalLines ?? 0,
1179
+ lang: r.lang,
1180
+ content: [{ type: 'text', text: r.content ?? '' }],
1181
+ }
1182
+ },
1183
+ }))
1064
1184
  }