@danceiny/gotry 0.2.0-rc.27 → 0.2.0-rc.28

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 (64) hide show
  1. package/README.md +2 -2
  2. package/README.zh-CN.md +2 -2
  3. package/bin/gotry-bootstrap.js +47 -3
  4. package/dist/capabilities/anything.js +26 -0
  5. package/dist/capabilities/doctor.js +11 -4
  6. package/dist/capabilities/ground-transfer.js +168 -1
  7. package/dist/capabilities/provider-detail-sanitize.js +64 -0
  8. package/dist/capabilities/route-provider-conformance.js +341 -0
  9. package/dist/scripts/anything-path-measure-tests.js +133 -0
  10. package/dist/scripts/anything-path-measure.js +150 -0
  11. package/dist/scripts/anything-tests.js +136 -2
  12. package/dist/scripts/booking-copilot-dsh-warmer-proof.js +5 -2
  13. package/dist/scripts/bootstrap-tests.js +71 -1
  14. package/dist/scripts/city-scenario-tier-tests.js +234 -0
  15. package/dist/scripts/doctor-tests.js +37 -1
  16. package/dist/scripts/drill-multiuser-ledger-tests.js +1076 -0
  17. package/dist/scripts/drill-sdk-descendant-cleanup-tests.js +606 -0
  18. package/dist/scripts/drill-w2a-sensor-tests.js +788 -0
  19. package/dist/scripts/dsh-llm-tests.js +437 -0
  20. package/dist/scripts/g5-guard-tests.js +20 -1
  21. package/dist/scripts/g5-guard.js +5 -1
  22. package/dist/scripts/m3-cohort-tests.js +714 -0
  23. package/dist/scripts/m3-cohort.js +310 -0
  24. package/dist/scripts/nightly-evidence-tests.js +44 -2
  25. package/dist/scripts/nightly-evidence.js +21 -4
  26. package/dist/scripts/outcome-projection-tests.js +427 -0
  27. package/dist/scripts/persona-sim-tests.js +603 -0
  28. package/dist/scripts/persona-sim.js +1081 -0
  29. package/dist/scripts/replay-async.js +2 -0
  30. package/dist/scripts/replay.js +2 -0
  31. package/dist/scripts/route-provider-conformance-tests.js +391 -0
  32. package/dist/scripts/session-zone-crash.js +49 -0
  33. package/dist/scripts/session-zone-ledger-tests.js +432 -0
  34. package/dist/scripts/session-zone-observation-tests.js +492 -0
  35. package/dist/scripts/session-zone-observe.js +135 -0
  36. package/dist/scripts/session-zone-wiring-tests.js +566 -0
  37. package/dist/scripts/session-zones-tests.js +17 -1
  38. package/dist/scripts/spawn-bounded-abort-tests.js +3 -0
  39. package/dist/scripts/state-cli.js +16 -0
  40. package/dist/scripts/time-eval-tests.js +2 -0
  41. package/dist/scripts/unified-tests.js +37 -1
  42. package/dist/scripts/z3-race-tests.js +6 -1
  43. package/dist/src/city-scenario-tier.js +378 -0
  44. package/dist/src/dsh-llm.js +84 -14
  45. package/dist/src/index.js +160 -1
  46. package/dist/src/loop.js +16 -5
  47. package/dist/src/m3-cohort.js +1577 -0
  48. package/dist/src/outcome-projection.js +746 -0
  49. package/dist/src/session-zone-ledger.js +312 -0
  50. package/dist/src/session-zone-observation.js +354 -0
  51. package/dist/src/session-zone-wiring.js +658 -0
  52. package/dist/src/session-zones.js +7 -4
  53. package/dist/src/state-ledger.js +293 -40
  54. package/dist/src/unified.js +69 -5
  55. package/extension/manifest.json +2 -2
  56. package/package.json +1 -1
  57. package/ts/capabilities/anything.ts +27 -0
  58. package/ts/scripts/state-cli.ts +18 -0
  59. package/ts/src/contracts.ts +4 -0
  60. package/ts/src/dsh-llm.ts +111 -9
  61. package/ts/src/index.ts +175 -2
  62. package/ts/src/loop.ts +15 -5
  63. package/ts/src/state-ledger.ts +294 -40
  64. package/ts/src/unified.ts +91 -18
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@danceiny/gotry",
3
- "version": "0.2.0-rc.27",
3
+ "version": "0.2.0-rc.28",
4
4
  "description": "GoTry — 从出发到下一次出发的 AI 旅行 Agent(dsh 插件)。npm/源码入口共用锁定的 dsh runtime + README 安装路径。",
5
5
  "type": "module",
6
6
  "main": "ts/src/index.ts",
@@ -19,6 +19,12 @@
19
19
  * - miss: hbcli exit 0,但 candidates 为空(查无结果)
20
20
  * - err: hbcli exit ≠ 0 或超时(降级,降级产物 标 [实时API:hbcli-anything@error@ts])
21
21
  *
22
+ * 离线开关(issue #617;与会话通道 GOTRY_SESSION_LIVE 同形,方向相反以保持产品默认不变):
23
+ * GOTRY_HBCLI_LIVE=0|false|off(不分大小写)→ 零 spawn、不碰凭据与后端,走同一条降级面
24
+ * (verdict=error,证据标 [实时API:hbcli-anything@offline@ts]);未设或任何其他取值 = 与此前
25
+ * 完全一致(hbcli 可用即 live)。判定见 hbcliLiveEnabled;mock/dry-run harness(nightly-evidence --dry-run、
26
+ * persona-sim、replay*)缺省把它置 0,operator 显式设置的值不覆盖。
27
+ *
22
28
  * 契约(与 hbcli/weather/opensky 同构,L4 不变量):
23
29
  * - 永不抛错:网络/超时/解析失败一律降级返回;
24
30
  * - 证据链标注:成功 [实时API:hbcli-anything@ts];失败 [实时API:hbcli-anything@error@ts];
@@ -157,6 +163,16 @@ function failureReason(
157
163
  : `hbcli 退出码 ${r.code},未返回可判定结果(本轮按降级处理)`) + suffix
158
164
  }
159
165
 
166
+ /**
167
+ * 实时 hbcli 通道是否开启(issue #617)。只有显式写 0 / false / off(不分大小写,忽略首尾空白)
168
+ * 才关闭;未设或任何其他取值都视为开启——终端用户依赖的实时源缺省不丢。调用时读取,
169
+ * 测试与 harness 可在进程内切换。
170
+ */
171
+ export function hbcliLiveEnabled(env: Partial<Pick<NodeJS.ProcessEnv, 'GOTRY_HBCLI_LIVE'>> = process.env): boolean {
172
+ const v = (env.GOTRY_HBCLI_LIVE ?? '').trim().toLowerCase()
173
+ return v !== '0' && v !== 'false' && v !== 'off'
174
+ }
175
+
160
176
  /** Anything 通用搜索 — 任何搜索失败走降级;不抛错 */
161
177
  export async function anythingSearch(q: AnythingQuery): Promise<AnythingResult> {
162
178
  const started = Date.now()
@@ -180,6 +196,17 @@ export async function anythingSearch(q: AnythingQuery): Promise<AnythingResult>
180
196
  latencyMs: 0, verdict: 'error', error: 'aborted by host signal',
181
197
  }
182
198
  }
199
+ // 离线开关:位置在入参校验与 pre-abort 之后,既有入参的既有结果/原因串逐字节不变;
200
+ // 关闭时与「未装配 hbcli」同形降级(ok:false / hbcli-anything-error / verdict:error + 人话原因,
201
+ // 无进程噪音,也不含 timeout 字样——效应层只重试 timeout 类),且不 spawn、不重试。
202
+ if (!hbcliLiveEnabled()) {
203
+ return {
204
+ ok: false, via: 'hbcli-anything-error',
205
+ evidence: `[实时API:hbcli-anything@offline@${ts}] GOTRY_HBCLI_LIVE switched off; live channel closed, zero spawn`,
206
+ latencyMs: Date.now() - started, verdict: 'error',
207
+ error: 'hbcli 实时通道已关闭(GOTRY_HBCLI_LIVE 离线开关;本轮按降级处理,未发起查询)',
208
+ }
209
+ }
183
210
  let r: Awaited<ReturnType<typeof spawnBounded>> = { code: null, stdout: '', stderr: '', aborted: false, timedOut: false }
184
211
  for (let i = 0; i < candidates.length; i++) {
185
212
  r = await sh(candidates[i]!, args, { timeoutMs: q.timeoutMs ?? 12_000, env: process.env, signal: q.signal })
@@ -4,6 +4,8 @@
4
4
  * migrate [root] one-shot 迁移:旧 JSON/JSONL → 账本 events
5
5
  * (导入前自动快照到 gotry-state/pre-ledger-backup/)
6
6
  * export [root] local-only:账本 → 旧文件名视图(红线 6:可见可导出;单向,不回流)
7
+ * 另含会话双区派生视图(P4-2):hot-context.jsonl /
8
+ * notebook.json,从事件 fold 渲染——视图永不是写路径
7
9
  * log [root] [--limit N] 事件账本尾部(append-only 审计面)
8
10
  * stats [root] 各面计数
9
11
  * rebuild [root] [toSeq] DROP 投影 → fold 重放(可截到 toSeq;账本/投影
@@ -48,6 +50,11 @@
48
50
  import { mkdirSync, renameSync, writeFileSync } from 'node:fs'
49
51
  import { dirname, join } from 'node:path'
50
52
  import { ensureLedger, ledgerDbPath, ledgerExists, openLedgerIfExists } from '../src/state-ledger.ts'
53
+ import {
54
+ ZONE_EXPORT_HOT_FILE,
55
+ ZONE_EXPORT_NOTEBOOK_FILE,
56
+ readZoneExportViews,
57
+ } from '../src/session-zone-ledger.ts'
51
58
  import { formatRepairPlan, loadEvidenceMappingFile, planLedgerRepair } from '../src/ledger-repair-plan.ts'
52
59
  import {
53
60
  applyLedgerRepair,
@@ -405,6 +412,17 @@ switch (cmd) {
405
412
  if (utility.length) atomicWrite(join(dir, 'memory-utility.jsonl'), utility.map(e => JSON.stringify(e)).join('\n') + '\n')
406
413
  const trips = ledger.readTrips()
407
414
  if (trips.length) atomicWrite(join(dir, 'trips.jsonl'), trips.map(e => JSON.stringify(e)).join('\n') + '\n')
415
+ // 会话双区派生视图(P4-2,design/session-dual-zone-memory-design.md §2):
416
+ // 从六个事件 kind 的确定性 fold 渲染;空分区不产生空文件(同既有视图纪律)。
417
+ // 日志触读上界即拒:导出残缺视图比不导出更坏(读者会当它是全量)。
418
+ const zone = readZoneExportViews(ledger)
419
+ if (!zone.ok) {
420
+ console.error(`${zone.code}: ${zone.detail}`)
421
+ process.exit(1)
422
+ }
423
+ const zoneViews = zone.views
424
+ if (zoneViews.hotContextJsonl) atomicWrite(join(dir, ZONE_EXPORT_HOT_FILE), zoneViews.hotContextJsonl)
425
+ if (zoneViews.notebookJson !== '[]') atomicWrite(join(dir, ZONE_EXPORT_NOTEBOOK_FILE), zoneViews.notebookJson)
408
426
  console.log(`视图已导出(单向,DB→文件;红线 6):${dir}/`)
409
427
  break
410
428
  }
@@ -76,11 +76,15 @@ export interface TripState {
76
76
  // ---- 求解结果(unified 求解器输出的契约化引用) ----------------------------------
77
77
 
78
78
  export interface SolveResult {
79
+ /** `solver_error` 存在时,`feasible: false` 只表示「没有产出可行解」,
80
+ * **不表示「不可行」**(issue #620):那一轮求解器自己失败了,判定缺席。 */
79
81
  feasible: boolean
80
82
  money_cny?: number
81
83
  legs?: Array<Record<string, unknown> & { leg: string }>
82
84
  red_flags?: string[]
83
85
  unsat_core?: string[]
86
+ /** 求解器自身失败(非判定)。机器 token 只住 `code`,不进用户面「冲突」文案。 */
87
+ solver_error?: { code: string; message: string }
84
88
  suggestions?: Array<{ relax: string; money_cny?: number }>
85
89
  work_window_exclusions?: Array<{ segment: string; option: string; reason: string }>
86
90
  skeleton_notes?: string[]
package/ts/src/dsh-llm.ts CHANGED
@@ -1,7 +1,8 @@
1
1
  /**
2
2
  * 真 LlmPort:OpenAI 兼容接口的 provider 中立适配器(DeepSeek/MiniMax-M2 实测)。
3
3
  * 零新依赖(node 内建 fetch)。环境变量:LLM_API_KEY/LLM_BASE_URL/LLM_MODEL
4
- * (兼容旧 DEEPSEEK_* 别名)。MiniMax-M2 是推理模型:输出带 <think> 块,
4
+ * (兼容旧 DEEPSEEK_* 别名);GOTRY_LLM_TIMEOUT_MS = 单次请求总预算(默认 300000ms,
5
+ * 非正整数忽略)。MiniMax-M2 是推理模型:输出带 <think> 块,
5
6
  * 必须先剥离再解析——JSON 藏在 think 里是常见失败模式。
6
7
  * 无 key 时抛出明确错误,replay-real 自动回退 mock(ADR-8)。
7
8
  * 责任铁律不变:本适配器只做翻译/润色/解释,判定与算术在确定性组件。
@@ -37,11 +38,100 @@ function emptyUsage(): LlmUsageTracker {
37
38
  return { calls: 0, inputTokens: 0, outputTokens: 0, inputCacheHitTokens: 0, inputCacheMissTokens: 0, responsesMissingUsage: 0 }
38
39
  }
39
40
 
40
- async function chat(messages: Array<{ role: 'system' | 'user' | 'assistant'; content: string }>, json: boolean, usage?: LlmUsageTracker): Promise<string> {
41
+ /** 单次请求(连接→响应头→正文读完)的默认总预算:对推理模型足够宽松,只兜「连上了却永不应答」的卡死。 */
42
+ export const DEFAULT_LLM_TIMEOUT_MS = 300_000
43
+ /**
44
+ * 超时上限 = 2^31-1 ms(≈24.8 天)。Node 对更大的延迟会溢出并**悄悄改成 1ms**
45
+ * (TimeoutOverflowWarning),即「配得越大越立刻超时」——所以必须夹住,绝不原样透传。
46
+ */
47
+ const MAX_LLM_TIMEOUT_MS = 2_147_483_647
48
+
49
+ /**
50
+ * chat() 的请求级选项。timeoutMs 压过环境变量 GOTRY_LLM_TIMEOUT_MS;signal 由调用方持有并与
51
+ * 超时合并(AbortSignal.any)——任一先触发就终止本次请求,落成 LlmRequestError。
52
+ */
53
+ export interface LlmRequestOptions {
54
+ timeoutMs?: number
55
+ signal?: AbortSignal
56
+ }
57
+
58
+ /** 只认十进制正整数(数值或纯数字串);其余(0/负数/小数/NaN/带单位/空串)一律 undefined——绝不退化成「无超时」。 */
59
+ function parseTimeoutMs(raw: unknown): number | undefined {
60
+ const n = typeof raw === 'string' ? (/^\d+$/.test(raw.trim()) ? Number(raw.trim()) : Number.NaN) : raw
61
+ return typeof n === 'number' && Number.isSafeInteger(n) && n > 0 ? Math.min(n, MAX_LLM_TIMEOUT_MS) : undefined
62
+ }
63
+
64
+ /** 解析顺序:本次调用显式值 > 环境变量 GOTRY_LLM_TIMEOUT_MS(调用时取值,同 base()/model())> 默认值。 */
65
+ export function resolveLlmTimeoutMs(callMs?: number): number {
66
+ return parseTimeoutMs(callMs) ?? parseTimeoutMs(process.env['GOTRY_LLM_TIMEOUT_MS']) ?? DEFAULT_LLM_TIMEOUT_MS
67
+ }
68
+
69
+ export type LlmRequestFailureCode = 'timeout' | 'aborted'
70
+
71
+ /**
72
+ * chat() 请求被截断(超时 / 调用方取消)的类型化失败——不是挂起,也不会被吞。
73
+ * 与既有错误形态一致,而非另起一套:
74
+ * - message 沿用本模块 `llm <kind>: <detail>` 约定(同 `llm 429: …`),只读 .message 的调用方
75
+ * (nightly-evidence / persona-sim / time-eval --real)原样可用;
76
+ * - name 取平台既有名 TimeoutError / AbortError(AbortSignal.timeout 与手动 abort 抛的同名),
77
+ * persona-sim、channel-probe 等按 name 分类的代码零改动即可识别;
78
+ * - cause 保留 fetch 抛出的原始错误(调用方自定义的 abort reason 也在其中)。
79
+ * HTTP 非 2xx(`llm <status>`)、DNS/连接重置、坏 JSON 仍是原样的普通 Error——只改写「被我们的 signal 终止」的失败。
80
+ */
81
+ export class LlmRequestError extends Error {
82
+ readonly code: LlmRequestFailureCode
83
+ /** 本次生效的超时预算;仅 code === 'timeout' 有值 */
84
+ readonly timeoutMs: number | undefined
85
+
86
+ constructor(code: LlmRequestFailureCode, detail: string, options: { cause?: unknown; timeoutMs?: number } = {}) {
87
+ super(`llm ${code}: ${detail}`, options.cause === undefined ? undefined : { cause: options.cause })
88
+ this.name = code === 'timeout' ? 'TimeoutError' : 'AbortError'
89
+ this.code = code
90
+ this.timeoutMs = options.timeoutMs
91
+ }
92
+ }
93
+
94
+ interface ChatCompletion {
95
+ choices: Array<{ message: { content: string } }>
96
+ usage?: { prompt_tokens?: number; completion_tokens?: number; prompt_cache_hit_tokens?: number; prompt_cache_miss_tokens?: number }
97
+ }
98
+
99
+ /**
100
+ * 发一次请求并读完正文。**同一个 signal 贯穿 fetch 与 res.json()/res.text()**,所以超时覆盖整个请求
101
+ * (含「响应头已到、正文卡住」与「非 2xx 的错误正文卡住」),调用方取消同理。
102
+ * 只有「被我们的 signal 终止」才改写成 LlmRequestError;其余错误原样上抛。
103
+ */
104
+ async function requestCompletion(
105
+ url: string,
106
+ init: { headers: Record<string, string>; body: string },
107
+ request: LlmRequestOptions,
108
+ ): Promise<{ ok: true; data: ChatCompletion } | { ok: false; status: number; text: string }> {
109
+ const timeoutMs = resolveLlmTimeoutMs(request.timeoutMs)
110
+ const timeout = AbortSignal.timeout(timeoutMs)
111
+ const signal = request.signal ? AbortSignal.any([request.signal, timeout]) : timeout
112
+ try {
113
+ const res = await fetch(url, { method: 'POST', headers: init.headers, body: init.body, signal })
114
+ if (!res.ok) return { ok: false, status: res.status, text: await res.text() }
115
+ return { ok: true, data: await res.json() as ChatCompletion }
116
+ } catch (error) {
117
+ if (!signal.aborted) throw error
118
+ // AbortSignal.any 的 reason 是最先触发的那个源的 reason:等于超时信号的 reason 即超时先到,否则是调用方取消
119
+ if (timeout.aborted && signal.reason === timeout.reason) {
120
+ throw new LlmRequestError('timeout', `no complete response within ${timeoutMs}ms (GOTRY_LLM_TIMEOUT_MS)`, { cause: error, timeoutMs })
121
+ }
122
+ throw new LlmRequestError('aborted', "request cancelled by the caller's AbortSignal", { cause: error })
123
+ }
124
+ }
125
+
126
+ async function chat(
127
+ messages: Array<{ role: 'system' | 'user' | 'assistant'; content: string }>,
128
+ json: boolean,
129
+ usage?: LlmUsageTracker,
130
+ request: LlmRequestOptions = {},
131
+ ): Promise<string> {
41
132
  const key = process.env['LLM_API_KEY'] ?? process.env['DEEPSEEK_API_KEY']
42
133
  if (!key) throw new Error('LLM_API_KEY 未设置(兼容 DEEPSEEK_API_KEY 别名)——真 LLM 路径不可用,请回退 mock(ADR-8)')
43
- const res = await fetch(`${base()}/chat/completions`, {
44
- method: 'POST',
134
+ const reply = await requestCompletion(`${base()}/chat/completions`, {
45
135
  headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${key}` },
46
136
  body: JSON.stringify({
47
137
  model: model(),
@@ -49,9 +139,9 @@ async function chat(messages: Array<{ role: 'system' | 'user' | 'assistant'; con
49
139
  ...(json ? { response_format: { type: 'json_object' } } : {}),
50
140
  temperature: json ? 0 : 0.7,
51
141
  }),
52
- })
53
- if (!res.ok) throw new Error(`llm ${res.status}: ${(await res.text()).slice(0, 300)}`)
54
- 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 } }
142
+ }, request)
143
+ if (!reply.ok) throw new Error(`llm ${reply.status}: ${reply.text.slice(0, 300)}`)
144
+ const data = reply.data
55
145
  if (usage) {
56
146
  usage.calls += 1
57
147
  const u = data.usage
@@ -94,7 +184,11 @@ const SKELETON_SYSTEM = `你是行程骨架抽取器。从对话中抽取行程
94
184
  规则:每个跨城移动一段;锚点只放用户明说或必然的(如"当天到"→arriveByMin 23:59=1439);时刻用当日分钟。
95
185
  scenario 判定:「洱海/大理/千岛湖/太湖+选目的地」→erhai(候选集);「普吉/workation/远程办公+多城链」→workation(五段链);「云南/大理丽江」→yunnan;不确定→generic。只输出 JSON。`
96
186
 
97
- export function createOpenAICompatLlm(flightPackPath?: string, clock: () => Date = () => new Date()): LlmPort & { usage: LlmUsageTracker } {
187
+ /**
188
+ * request:本端口每次 chat() 共用的请求级选项(timeoutMs / signal)。signal 是端口级取消——
189
+ * 调用方可借它在会话/批次截止时真正掐断在途请求,而不是放着它继续挂在 provider 上。
190
+ */
191
+ export function createOpenAICompatLlm(flightPackPath?: string, clock: () => Date = () => new Date(), request: LlmRequestOptions = {}): LlmPort & { usage: LlmUsageTracker } {
98
192
  const pack = flightPackPath
99
193
  const usage = emptyUsage()
100
194
  const historyText = (h: Turn[]) => h.map(t => `${t.role === 'user' ? '用户' : '助手'}: ${t.text}`).join('\n')
@@ -107,6 +201,7 @@ export function createOpenAICompatLlm(flightPackPath?: string, clock: () => Date
107
201
  [{ role: 'system', content: FACTS_SYSTEM }, { role: 'user', content: `${anchorContext()}\n\n${historyText(history)}` }],
108
202
  true,
109
203
  usage,
204
+ request,
110
205
  )
111
206
  const obj = parseJsonBlock(out)
112
207
  if (!obj) return { assumptions: [] }
@@ -122,6 +217,7 @@ export function createOpenAICompatLlm(flightPackPath?: string, clock: () => Date
122
217
  [{ role: 'system', content: SKELETON_SYSTEM }, { role: 'user', content: `${context}\n\n${historyText(history)}` }],
123
218
  true,
124
219
  usage,
220
+ request,
125
221
  )
126
222
  const skeleton = parseJsonBlock(out)
127
223
  if (!skeleton || !Array.isArray(skeleton['segments']) || (skeleton['segments'] as unknown[]).length === 0) return null
@@ -147,7 +243,11 @@ export function createOpenAICompatLlm(flightPackPath?: string, clock: () => Date
147
243
  return { segments: [], note: 'erhai-candidates', budgetCny: 3000 } as unknown as JourneySpecTS
148
244
  }
149
245
  packSpec = parseFlightPackToSpec(JSON.parse(await readFile(packPath, 'utf-8')))
150
- } catch {
246
+ } catch (e) {
247
+ // issue #620:数据包被 parse 边界拒收时,原因必须留痕——否则用户只看到
248
+ // 「骨架还不完整」,运维看不到是哪个包的哪个字段把求解面挡住了。
249
+ const err = e as Error & { code?: string }
250
+ console.error(`[gotry] extractSpec: 数据包不可用 ${packPath}(${err.code ?? 'parse_failed'}):`, (err.message ?? '').slice(0, 200))
151
251
  return null
152
252
  }
153
253
  const anchorsById = new Map<string, Record<string, unknown>>(
@@ -167,6 +267,7 @@ export function createOpenAICompatLlm(flightPackPath?: string, clock: () => Date
167
267
  [{ role: 'system', content: buildSlotSystem(anchor) }, { role: 'user', content: historyText(history) }],
168
268
  true,
169
269
  usage,
270
+ request,
170
271
  )
171
272
  const obj = parseJsonBlock(out)
172
273
  if (!obj) return null
@@ -180,6 +281,7 @@ export function createOpenAICompatLlm(flightPackPath?: string, clock: () => Date
180
281
  { role: 'user', content: `【${q.key}】${q.text}(为什么问:${q.why})` }],
181
282
  false,
182
283
  usage,
284
+ request,
183
285
  )
184
286
  return out.trim() || `【${q.key}】${q.text}`
185
287
  },
package/ts/src/index.ts CHANGED
@@ -27,7 +27,19 @@ import { interpretArgs, type GotryObservation } from './tool-packet.ts'
27
27
  import { projectUtility } from './memory-utility.ts'
28
28
  import { pickNudgeWish, type WishPoolEntry } from './wish-pool.ts'
29
29
  import { resolveTimelineDate } from './travel-timeline.ts'
30
- import { ensureLedger, readCompanionsWithFallback, readMotivationWithFallback, readTripsWithFallback, readWishPoolWithFallback } from './state-ledger.ts'
30
+ import { ensureLedger, openLedgerIfExists, readCompanionsWithFallback, readMotivationWithFallback, readTripsWithFallback, readWishPoolWithFallback } from './state-ledger.ts'
31
+ import {
32
+ applyZoneNote,
33
+ bindZoneSession,
34
+ boundZoneSession,
35
+ isZoneObservableTool,
36
+ observeToolResult,
37
+ promoteWithRouting,
38
+ renderSessionZoneBrief,
39
+ resolveZoneSwitch,
40
+ zoneScopeOfExec,
41
+ zoneSessionRefFromExec,
42
+ } from './session-zone-wiring.ts'
31
43
  import { isValidHomeCityPreference, resolveDefaultOrigin, type MergedProfile, type ProfilePatch } from './memory-capture.ts'
32
44
  import { applyPlanningWindow } from './loop.ts'
33
45
  import { buildTimeAnchor, type PlanningWindow } from './time-anchor.ts'
@@ -105,6 +117,8 @@ export interface Config {
105
117
  hbcliBin: string
106
118
  /** 账号会话检索总闸(RFC 支柱④「用户明示授权+随时可关」):ask=gotry_session_search 每会话每站点首次调用弹审批卡、会话内记住(默认);allow=用户已在配置明示预授权(直接放行);off=总闸关闭,直接拒绝 */
107
119
  sessionAccess: string
120
+ /** 会话双区记忆总闸(issue #255 P4-3):off=默认(缺省/未知值同样 fail-closed 关),接线完全惰性(零账本事件/读回空串/工具不注册);on=创始人本地启用 */
121
+ sessionZones?: string
108
122
  /** Owner-local benchmark environment bridge config; empty disables the bridge. */
109
123
  benchmarkEnvironmentConfigPath?: string
110
124
  /** Trusted absolute path of the installed `lavish-axi@0.1.67` package; empty disables the local Lavish Editor product tools. */
@@ -116,6 +130,7 @@ export const Config: z<Config> = z.object({
116
130
  timeoutMs: z.number().default(30_000),
117
131
  hbcliBin: z.string().default('hbcli'),
118
132
  sessionAccess: z.string().default('ask'),
133
+ sessionZones: z.string().default('off'),
119
134
  benchmarkEnvironmentConfigPath: z.string().default(''),
120
135
  lavishAxiPackageRoot: z.string().default(''),
121
136
  })
@@ -533,8 +548,11 @@ export function apply(ctx: Context, config: Config, seams: ApplyTestSeams = {}):
533
548
 
534
549
  // 时间感知:注册动态变量,persona 里用 {{current_date}} 引用。
535
550
  // 每次 assemble 时取系统时钟——LLM 始终知道「今天是几号」。
551
+ // provider 收到本次组装的 AssembleContext(dsh-system-prompt 契约:
552
+ // `variable(name, provider: (context: AssembleContext) => string | undefined)`);
553
+ // `scope` 是不透明、按身份比较的 ScopeKey —— 分区读回据此分辨会话。
536
554
  const sp = (ctx as unknown as Record<string, unknown>)['systemPrompt'] as {
537
- variable?: (name: string, provider: () => string) => void
555
+ variable?: (name: string, provider: (context?: { scope?: object }) => string) => void
538
556
  } | undefined
539
557
  sp?.variable?.('current_date', () => {
540
558
  const d = new Date()
@@ -551,6 +569,33 @@ export function apply(ctx: Context, config: Config, seams: ApplyTestSeams = {}):
551
569
  // 渲染成紧凑 brief 注入 persona;为空 = 首访。与当轮说法冲突时以用户为准。
552
570
  sp?.variable?.('motivation_brief', () => renderMotivationBrief(config.stateRoot ?? '.'))
553
571
 
572
+ // 会话双区记忆读回(issue #255 P4-3,design/session-dual-zone-memory-design.md §5):
573
+ // 变量恒注册(persona 侧可随时引用),但总闸 off(默认)时恒返回空串 —— 接线惰性,
574
+ // 不建库、不读库、零事件;persona 模板本身不在本 PR 改动,因此开关 off 时注入面
575
+ // 输出逐字节不变。空串 = 首访/无活笔记(与 motivation_brief 同纪律)。
576
+ // 会话双区读回(issue #255 P4-3):**只在总闸 on 时注册**。
577
+ // - 为什么是条件注册:默认关必须与 main **不可区分**——注入面多出一个变量名
578
+ // 就已经是可观测差异(§48 benchmark-environment-bridge-tests 的
579
+ // 「normal product mode keeps its prompt variables」逐项断言会红)。
580
+ // 出厂 persona(cordis.gotry-patch.yml)今天不引用本变量,所以条件注册安全。
581
+ // - **后续约束**:创始人一旦把 `{{session_zone_brief}}` 写进出厂 persona,
582
+ // 本变量必须改为**无条件注册**(关闸时返回空串)——strict 插值下未注册的
583
+ // 引用会让整段 persona 渲染失败。那一步同时要改 §48 的变量清单断言、
584
+ // persona-surface-guard-tests 的注入面清单与 benchmark-environment-bridge-e2e
585
+ // 的 CANONICAL_RAW_VARIABLES。
586
+ // - 读回按**组装 scope** 取会话身份(dsh agent-loop 以 agent 对象为 ScopeKey,
587
+ // 与工具侧 exec.agent 同一身份):同一宿主进程里的两个会话因此读不到对方的
588
+ // resource 层笔记(设计 §1.1);取不到 scope → 不出 resource 段(fail-closed)。
589
+ const zoneSwitch = resolveZoneSwitch(config.sessionZones)
590
+ if (zoneSwitch === 'on') {
591
+ sp?.variable?.('session_zone_brief', assembleContext => renderSessionZoneBrief({
592
+ ledger: openLedgerIfExists(config.stateRoot ?? '.'),
593
+ now: new Date().toISOString(),
594
+ sessionRef: boundZoneSession(assembleContext?.scope),
595
+ zoneSwitch,
596
+ }))
597
+ }
598
+
554
599
  // 通道路由卡(通道注册表生成,docs/design/tool-orchestration-design.md §2.1/D-8):
555
600
  // persona 检索条款只留行为契约,机/火/酒通道顺位与额度口径查卡——prose 教义
556
601
  // 变查表教义,注册表加通道卡片自动一致。
@@ -631,6 +676,29 @@ export function apply(ctx: Context, config: Config, seams: ApplyTestSeams = {}):
631
676
  if (typeof t.execute === 'function') {
632
677
  t.execute = guardToolExecute(String(t.name), config.stateRoot ?? '.', t.execute as (args: never, exec: unknown) => never)
633
678
  }
679
+ // 工具观察捕获缝(issue #255 P4-3):总闸 on 且工具在闭集白名单内才包裹——
680
+ // off(默认)时 execute 一层不多包,行为逐字节不变。捕获只投影形状字段
681
+ // (verdict/条数/价格带),证据只走指针;捕获失败永不影响工具返回值。
682
+ if (zoneSwitch === 'on' && typeof t.execute === 'function' && isZoneObservableTool(t.name)) {
683
+ const inner = t.execute as (args: never, exec: unknown) => Promise<unknown>
684
+ const toolName = String(t.name)
685
+ t.execute = async (args: never, exec: unknown): Promise<unknown> => {
686
+ const result = await inner(args, exec)
687
+ try {
688
+ const sessionRef = zoneSessionRefFromExec(exec)
689
+ if (sessionRef) {
690
+ bindZoneSession(zoneScopeOfExec(exec), sessionRef)
691
+ observeToolResult(openLedgerIfExists(config.stateRoot ?? '.') ?? ensureLedger(config.stateRoot ?? '.'), {
692
+ tool: toolName,
693
+ result,
694
+ session_ref: sessionRef,
695
+ ts: new Date().toISOString(),
696
+ })
697
+ }
698
+ } catch { /* 记忆面永不把产品主路径带红 */ }
699
+ return result
700
+ }
701
+ }
634
702
  ctx.tools.register(t as unknown as ReturnType<typeof defineTool>)
635
703
  }
636
704
 
@@ -1156,6 +1224,111 @@ export function apply(ctx: Context, config: Config, seams: ApplyTestSeams = {}):
1156
1224
  presentCall: args => ({ card: 'generic', title: '记录旅行', kind: 'edit', rawInput: args }),
1157
1225
  }))
1158
1226
 
1227
+ // 会话双区记忆工具面(issue #255 P4-3):**只在总闸 on 时注册**——off(默认)时
1228
+ // 工具清单逐项不变,模型看不到任何分区入口(接线惰性的第一道)。
1229
+ if (zoneSwitch === 'on') {
1230
+ registerGuarded(defineTool({
1231
+ name: 'gotry_session_zone_note',
1232
+ description:
1233
+ 'Session working-zone memory (hot context, issue #255): capture a STRUCTURED fragment of what is still in play this session '
1234
+ + '(destination / date window / party size / budget stance = intent tier, 24h; search shapes = resource tier, 30min), '
1235
+ + 'revise it with optimistic CAS when the user corrects a detail, or drop it when it stops being true. '
1236
+ + 'Payload must be typed slot-spec-family fields — NEVER raw conversation text, IDs, phone numbers, URLs or credentials '
1237
+ + '(the write gate rejects those shapes). Evidence is a pointer (session + observation index), never content. '
1238
+ + 'action="propose" only PROPOSES moving a note into the durable notebook: it writes NOTHING; the owner must confirm in their own words, '
1239
+ + 'after which you call gotry_session_zone_promote with that quote. The model may never self-promote. '
1240
+ + 'action="deny" records that the owner declined to remember it long-term (writes nothing; stop re-proposing it this session).',
1241
+ parameters: {
1242
+ action: { type: 'string', required: true, enum: ['capture', 'revise', 'drop', 'propose', 'deny'], description: 'capture=新片段入工作区;revise=CAS 修订(需 noteId+expectedRev);drop=更正即弃;propose=提议晋升(零写入);deny=owner 否决提议(零写入)' },
1243
+ tier: { type: 'string', enum: ['resource', 'intent'], description: 'capture 必填:intent(目的地/日期/人数/预算立场,24h)或 resource(检索形态,30min)' },
1244
+ kind: { type: 'string', enum: ['availability', 'price_band', 'destination', 'date_window', 'party_size', 'budget_stance'], description: 'capture 必填;revise 可选(改 kind)' },
1245
+ payload: { type: 'object', additionalProperties: true, description: '结构化片段,如 { city: "大理" } / { start: "2026-11-01", end: "2026-11-05" };禁放原文/证件/电话/URL/凭证' },
1246
+ noteId: { type: 'string', description: 'revise/drop/propose 的目标笔记 id' },
1247
+ expectedRev: { type: 'integer', description: 'revise 的父 rev(必须等于当前 rev,否则 stale_rev 拒绝)' },
1248
+ promoteKind: { type: 'string', enum: ['trip_fact', 'preference', 'constraint', 'lesson'], description: 'propose 时提议的持久条目类别' },
1249
+ },
1250
+ output: {
1251
+ schema: { type: 'json' },
1252
+ render: (_args, value) => [{ type: 'text', text: String((value as { summary?: string }).summary ?? '') }],
1253
+ },
1254
+ async execute(args, exec: unknown) {
1255
+ const sessionRef = zoneSessionRefFromExec(exec)
1256
+ if (!sessionRef) {
1257
+ return { ok: false, code: 'bad_session', summary: '无会话身份(exec.agent.session.id 缺失):分区不记忆无主体的片段' } as never
1258
+ }
1259
+ bindZoneSession(zoneScopeOfExec(exec), sessionRef)
1260
+ const r = applyZoneNote(ensureLedger(config.stateRoot), {
1261
+ action: args.action as never,
1262
+ ...(args.tier !== undefined ? { tier: args.tier } : {}),
1263
+ ...(args.kind !== undefined ? { kind: args.kind } : {}),
1264
+ ...(args.payload !== undefined ? { payload: args.payload as Record<string, unknown> } : {}),
1265
+ ...(args.noteId !== undefined ? { noteId: args.noteId } : {}),
1266
+ ...(args.expectedRev !== undefined ? { expectedRev: args.expectedRev } : {}),
1267
+ ...(args.promoteKind !== undefined ? { promoteKind: args.promoteKind } : {}),
1268
+ session_ref: sessionRef,
1269
+ ts: new Date().toISOString(),
1270
+ })
1271
+ if (!r.ok) return { ok: false, code: r.code, summary: r.detail } as never
1272
+ const summary = r.proposed
1273
+ ? `已提议(未写入):${r.ask ?? ''}`
1274
+ : `${r.action}${r.appended ? ' 已落账' : ' 幂等跳过'}${r.noteId ? `:${r.noteId.slice(0, 60)}` : ''}`
1275
+ return { ok: true, action: r.action, appended: r.appended, note_id: r.noteId, requires_owner_confirm: r.requiresOwnerConfirm === true, summary } as never
1276
+ },
1277
+ presentCall: args => ({ card: 'generic', title: `会话记忆:${String(args.action ?? '')}`, kind: 'edit', rawInput: args }),
1278
+ }))
1279
+
1280
+ registerGuarded(defineTool({
1281
+ name: 'gotry_session_zone_promote',
1282
+ description:
1283
+ 'Owner-confirmed promotion into the durable trip notebook (issue #255) — the ONLY write path into durable memory. '
1284
+ + 'Requires ownerQuote: the user\'s OWN words confirming they want this remembered long-term; your own summary is not a confirmation. '
1285
+ + 'Routing rule: a preference ALSO goes through the motivation-profile gate (pass motivation.{weights|hard|homeCity}), '
1286
+ + 'a trip fact through the timeline gate (pass trip.{destination,start}), a companion constraint through the companion gate (pass companion.{label,constraints}). '
1287
+ + 'Without the required routed payload the promotion is REJECTED — the notebook never becomes a parallel store for a fact that already has an owner. '
1288
+ + 'Generic constraints and lessons live in the notebook itself. The notebook write and the routed gate write are one transaction: they cannot diverge.',
1289
+ parameters: {
1290
+ noteId: { type: 'string', required: true, description: '源工作区笔记 id(必须未过期)' },
1291
+ kind: { type: 'string', required: true, enum: ['trip_fact', 'preference', 'constraint', 'lesson'], description: '持久条目类别' },
1292
+ ownerQuote: { type: 'string', required: true, description: '用户本人确认要长期记住的原话(≤200 字;模型自述不算确认)' },
1293
+ surface: { type: 'string', required: true, enum: ['user_reply', 'approval_card'], description: '确认表面:用户回复 或 审批卡' },
1294
+ payload: { type: 'object', additionalProperties: true, description: '可选:覆盖源笔记载荷的结构化断言' },
1295
+ motivation: { type: 'object', additionalProperties: true, description: 'preference 必填:{ weights?, hard?, homeCity? } 至少一项(经动机画像闸)' },
1296
+ trip: { type: 'object', additionalProperties: true, description: 'trip_fact 必填:{ destination, start(YYYY-MM-DD), end?, companions? }(经时间线闸)' },
1297
+ companion: { type: 'object', additionalProperties: true, description: '同行人约束必填:{ label, constraints:{ mobility?, health?, prefs? } }(经同行人闸)' },
1298
+ },
1299
+ output: {
1300
+ schema: { type: 'json' },
1301
+ render: (_args, value) => [{ type: 'text', text: String((value as { summary?: string }).summary ?? '') }],
1302
+ },
1303
+ async execute(args, exec: unknown) {
1304
+ const sessionRef = zoneSessionRefFromExec(exec)
1305
+ if (sessionRef) bindZoneSession(zoneScopeOfExec(exec), sessionRef)
1306
+ const r = promoteWithRouting(ensureLedger(config.stateRoot), {
1307
+ noteId: String(args.noteId ?? ''),
1308
+ kind: String(args.kind ?? ''),
1309
+ ownerQuote: String(args.ownerQuote ?? ''),
1310
+ surface: String(args.surface ?? ''),
1311
+ ...(args.payload !== undefined ? { payload: args.payload as Record<string, unknown> } : {}),
1312
+ ...(args.motivation !== undefined ? { motivation: args.motivation as never } : {}),
1313
+ ...(args.trip !== undefined ? { trip: args.trip as never } : {}),
1314
+ ...(args.companion !== undefined ? { companion: args.companion as never } : {}),
1315
+ ts: new Date().toISOString(),
1316
+ })
1317
+ if (!r.ok) return { ok: false, code: r.code, summary: r.detail } as never
1318
+ const routed = r.routed ? `;并经 ${r.routed.authority} 闸${r.routed.applied ? '落地' : '幂等跳过'}` : ''
1319
+ return {
1320
+ ok: true,
1321
+ appended: r.appended,
1322
+ entry_id: r.entryId,
1323
+ authority: r.authority,
1324
+ routed: r.routed ? JSON.parse(JSON.stringify(r.routed)) as JsonObject : null,
1325
+ summary: `${r.appended ? '已写入笔记本' : '笔记本幂等跳过'}${routed}`,
1326
+ } as never
1327
+ },
1328
+ presentCall: args => ({ card: 'generic', title: '笔记本晋升(owner 确认)', kind: 'edit', rawInput: args }),
1329
+ }))
1330
+ }
1331
+
1159
1332
  registerGuarded(defineTool({
1160
1333
  name: 'gotry_hotel_search',
1161
1334
  description: routed('gotry_hotel_search',
package/ts/src/loop.ts CHANGED
@@ -238,13 +238,18 @@ function terminalOutcomeFromState(ticketId: string, state: TripState): AsyncTerm
238
238
  })
239
239
  }
240
240
 
241
+ // issue #620:求解器自身失败时既没有产物也没有判定——2 条不成立;但「做不到诚实说」
242
+ // 恰恰成立(我们明说了是引擎故障而非不可行),不靠 unsat_core 冒充自检通过。
243
+ const solverFailed = Boolean(state.solve.solver_error)
241
244
  return asyncTerminalOutcome(ticketId, {
242
245
  '1_承诺时间后必有明确产物': Boolean(state.solve.legs?.length || state.solve.verdicts?.length),
243
- '2_产物通过自检清单': state.solve.feasible
244
- ? (state.solve.legs?.every(l => (l as Record<string, unknown>)['energy_pct'] !== undefined) ?? false)
245
- : Boolean(state.solve.unsat_core?.length),
246
+ '2_产物通过自检清单': solverFailed
247
+ ? false
248
+ : state.solve.feasible
249
+ ? (state.solve.legs?.every(l => (l as Record<string, unknown>)['energy_pct'] !== undefined) ?? false)
250
+ : Boolean(state.solve.unsat_core?.length),
246
251
  '3_待决问题全部是简单选择题': state.gates.every(g => g.id === 'budget' || g.options.length >= 2),
247
- '4_做不到的诚实说': state.solve.feasible || Boolean(state.solve.suggestions?.length),
252
+ '4_做不到的诚实说': solverFailed || state.solve.feasible || Boolean(state.solve.suggestions?.length),
248
253
  })
249
254
  }
250
255
 
@@ -292,7 +297,12 @@ export function renderSolve(state: TripState): string {
292
297
  const s = state.solve
293
298
  if (!s) return '(无求解结果)'
294
299
  const lines: string[] = []
295
- if (s.feasible) {
300
+ if (s.solver_error) {
301
+ // issue #620:求解器自己失败 ≠ 行程不可行。这里**不**说「不可行」、不列「冲突」,
302
+ // 也不把机器 token(solver_error.code)渲染给用户——它只在日志/证据里。
303
+ lines.push('**这轮没算出结论——求解器自身出错了,不是你的行程不可行**')
304
+ lines.push('- 我不把引擎故障说成「不可行」:这条已记录,修好或换候选形态(枚举)后重算')
305
+ } else if (s.feasible) {
296
306
  lines.push(`**方案可行,机票合计 ¥${s.money_cny}**`)
297
307
  // §7-1 issue #559 卡点 4:从 s.skeleton_notes 字符串编码里推断 unscaffolded 的 seg.id,
298
308
  // 不动 unified.ts/journey.ts(内核冻结面 #234),用户面给 ⚠ 前缀与「枢纽对未在骨架覆盖」补充。