@danceiny/gotry 0.0.1-rc.8 → 0.0.1-rc.9

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 (41) hide show
  1. package/README.md +6 -4
  2. package/cordis.gotry-patch.yml +7 -0
  3. package/dist/capabilities/flyai.js +131 -0
  4. package/dist/capabilities/session/action-cache.js +120 -0
  5. package/dist/capabilities/session/adapters/ctrip-flight.js +83 -0
  6. package/dist/capabilities/session/adapters/meituan-local.js +73 -0
  7. package/dist/capabilities/session/extract.js +40 -0
  8. package/dist/capabilities/session/read-guard.js +56 -0
  9. package/dist/capabilities/session/transport.js +95 -0
  10. package/dist/capabilities/session-search.js +95 -0
  11. package/dist/scripts/action-cache-tests.js +107 -0
  12. package/dist/scripts/async-collect.js +26 -7
  13. package/dist/scripts/companion-tests.js +112 -0
  14. package/dist/scripts/ledger-tests.js +344 -0
  15. package/dist/scripts/ledger-workflow-crash.js +42 -0
  16. package/dist/scripts/memory-decay-tests.js +83 -0
  17. package/dist/scripts/memory-metrics.js +6 -20
  18. package/dist/scripts/nudge-digest.js +4 -14
  19. package/dist/scripts/session-attach-diagnose.js +31 -0
  20. package/dist/scripts/session-attach-poc.js +65 -0
  21. package/dist/scripts/session-attach-wait.js +41 -0
  22. package/dist/scripts/session-extract-tests.js +81 -0
  23. package/dist/scripts/session-login.js +48 -0
  24. package/dist/scripts/session-tests.js +162 -0
  25. package/dist/scripts/smoke.js +41 -1
  26. package/dist/scripts/state-cli-tests.js +136 -0
  27. package/dist/scripts/state-cli.js +234 -0
  28. package/dist/scripts/travel-timeline-tests.js +123 -0
  29. package/dist/scripts/unified-tests.js +1 -1
  30. package/dist/src/companions.js +112 -0
  31. package/dist/src/index.js +346 -117
  32. package/dist/src/loop.js +52 -15
  33. package/dist/src/memory-decay.js +31 -0
  34. package/dist/src/state-ledger.js +848 -0
  35. package/dist/src/travel-timeline.js +78 -0
  36. package/dist/src/unified.js +3 -1
  37. package/package.json +1 -1
  38. package/ts/package.json +3 -0
  39. package/ts/src/index.ts +216 -83
  40. package/ts/src/loop.ts +57 -17
  41. package/ts/src/unified.ts +6 -1
@@ -0,0 +1,78 @@
1
+ import { parseAbsoluteDate, ymd } from './time-anchor.js';
2
+ import { resolveSlotDate } from './slot-spec.js';
3
+ export function resolveTimelineDate(expr, anchor) {
4
+ return resolveSlotDate(expr, anchor).date;
5
+ }
6
+ export function makeTripId(ev) {
7
+ return `${ev.destination}|${ev.start}|${ev.source}`.replace(/\s+/g, '_');
8
+ }
9
+ export function appendTrip(events, ev) {
10
+ if (!ev.destination?.trim()) return {
11
+ events,
12
+ appended: false,
13
+ reason: 'destination 必填'
14
+ };
15
+ if (!ev.evidence?.trim()) return {
16
+ events,
17
+ appended: false,
18
+ reason: 'evidence 必填(用户原话或 wish 确认指针)'
19
+ };
20
+ if (!/^\d{4}-\d{2}-\d{2}$/.test(ev.start)) return {
21
+ events,
22
+ appended: false,
23
+ reason: 'start 必须为 YYYY-MM-DD(词表外日期由上游解析,不猜)'
24
+ };
25
+ if (ev.end && ev.end < ev.start) return {
26
+ events,
27
+ appended: false,
28
+ reason: 'end 早于 start'
29
+ };
30
+ const tripId = makeTripId(ev);
31
+ if (events.some((e)=>e.trip_id === tripId)) return {
32
+ events,
33
+ appended: false,
34
+ tripId
35
+ };
36
+ const overlap = events.find((e)=>e.destination === ev.destination && !(e.end && e.end < ev.start) && !(ev.end && ev.end < e.start));
37
+ if (overlap) return {
38
+ events,
39
+ appended: false,
40
+ tripId: overlap.trip_id,
41
+ reason: `与已有行程 ${overlap.trip_id} 日期重叠,冲突即停`
42
+ };
43
+ const full = {
44
+ schema: 'travel_timeline.v1',
45
+ trip_id: tripId,
46
+ ts: new Date().toISOString(),
47
+ ...ev
48
+ };
49
+ return {
50
+ events: [
51
+ ...events,
52
+ full
53
+ ],
54
+ appended: true,
55
+ tripId
56
+ };
57
+ }
58
+ export function projectTimeline(events) {
59
+ return [
60
+ ...events
61
+ ].sort((a, b)=>a.start < b.start ? 1 : -1).map((e)=>({
62
+ tripId: e.trip_id,
63
+ destination: e.destination,
64
+ start: e.start,
65
+ end: e.end,
66
+ companions: e.companions
67
+ }));
68
+ }
69
+ export function timelineGapsForVerified(timeline, verifiedWishes) {
70
+ const dests = new Set(timeline.map((t)=>t.destination));
71
+ return verifiedWishes.filter((w)=>w.destination && !dests.has(w.destination)).map((w)=>({
72
+ wishId: w.wishId
73
+ }));
74
+ }
75
+ export { ymd };
76
+
77
+
78
+ //# sourceURL=/Users/bytedance/work/gotry/ts/src/travel-timeline.ts
@@ -179,7 +179,9 @@ function evaluateOptionMove(segId, mv) {
179
179
  let energy;
180
180
  if (mv.redEye && (mv.redEyeDurationMin ?? 0) > 0) {
181
181
  const sleepH = ((mv.redEyeDurationMin ?? 0) - 60) / 60;
182
- energy = Math.max(30, Math.min(75, 30 + 8 * sleepH));
182
+ const groundH = (mv.groundRecoveryMin ?? mv.destTransferMin ?? 0) / 60;
183
+ const recovery = Math.min(5, 5 * groundH);
184
+ energy = Math.max(30, Math.min(75 + recovery, 80, 30 + 8 * sleepH + recovery));
183
185
  } else {
184
186
  energy = 100 - 2 * 8;
185
187
  if (wake < 5 * 60) energy -= 30;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@danceiny/gotry",
3
- "version": "0.0.1-rc.8",
3
+ "version": "0.0.1-rc.9",
4
4
  "description": "GoTry — 从出发到下一次出发的 AI 旅行 Agent(dsh 插件)。npm 包入口 + vendored dsh runtime + 5 行 README 安装路径。",
5
5
  "type": "module",
6
6
  "main": "ts/src/index.ts",
package/ts/package.json CHANGED
@@ -13,10 +13,13 @@
13
13
  "@deepseek-ai/cordis": "*",
14
14
  "@deepseek-ai/dsh-tools": "*",
15
15
  "@deepseek-ai/schemastery": "*",
16
+ "@types/better-sqlite3": "^9.6.0",
17
+ "better-sqlite3": "^13.0.3",
16
18
  "z3-solver": "^5.2.0"
17
19
  },
18
20
  "devDependencies": {
19
21
  "@types/node": "^22.0.0",
22
+ "playwright-core": "^1.62.1",
20
23
  "tsx": "^4.19.0",
21
24
  "typescript": "^5.6.0"
22
25
  }
package/ts/src/index.ts CHANGED
@@ -14,21 +14,21 @@
14
14
  */
15
15
 
16
16
  import { join } from 'node:path'
17
- import { readFile, writeFile } from 'node:fs/promises'
18
- import { readFileSync } from 'node:fs'
17
+
19
18
  import type { Context } from '@deepseek-ai/cordis'
20
19
  import z from '@deepseek-ai/schemastery'
21
20
  import { defineTool } from '@deepseek-ai/dsh-tools'
22
- import { ensureStateDir, readJson, recordLatency, writeJson } from './bridge.ts'
21
+ import { ensureStateDir, recordLatency } from './bridge.ts'
23
22
  import { segmentsFromCandidate, solveChoiceSegment } from './unified.ts'
24
23
  import { checkConnectivity } from '../scripts/skeleton-check.ts'
25
24
  import { parseCandidate, parseRequest } from './model.ts'
26
25
  import { searchHotels as hbcliSearchHotels } from '../capabilities/hbcli.ts'
27
26
  import { installProcessGuards, guardToolExecute } from '../capabilities/incident-log.ts'
28
27
  import { interpretArgs, type GotryObservation } from './tool-packet.ts'
29
- import { appendEvent, projectUtility, type MemoryUtilityEvent } from './memory-utility.ts'
28
+ import { projectUtility } from './memory-utility.ts'
30
29
  import { pickNudgeWish, type WishPoolEntry } from './wish-pool.ts'
31
- import { mergeProfile } from './memory-capture.ts'
30
+ import { resolveTimelineDate } from './travel-timeline.ts'
31
+ import { ensureLedger, readCompanionsWithFallback, readMotivationWithFallback, readTripsWithFallback, readWishPoolWithFallback } from './state-ledger.ts'
32
32
  import { buildTimeAnchor } from './time-anchor.ts'
33
33
  import { resolveSlotDate } from './slot-spec.ts'
34
34
  import { geocodePlace, getForecast, getClimate, wmoLabel } from '../capabilities/weather.ts'
@@ -36,6 +36,8 @@ import { verifyFlight } from '../capabilities/opensky.ts'
36
36
  import { anythingSearch } from '../capabilities/anything.ts'
37
37
  import { readUrl, reach, reachStatus } from '../capabilities/agent-reach.ts'
38
38
  import { videoSubtitle, githubSearch } from '../capabilities/agent-reach-deep.ts'
39
+ import { flyaiSearch } from '../capabilities/flyai.ts'
40
+ import { sessionFlightSearch } from '../capabilities/session-search.ts'
39
41
 
40
42
  export const name = 'gotry-tools'
41
43
  export const inject = ['tools', 'systemPrompt']
@@ -81,24 +83,48 @@ interface WishPoolEntryInput {
81
83
  const unwrapQuery = interpretArgs
82
84
 
83
85
  /**
84
- * 动机画像 → persona 紧凑 brief(M4 T1 读回路径):空文件返回 ''(首访),
86
+ * 动机画像 → persona 紧凑 brief(M4 T1 读回路径):空画像返回 ''(首访),
85
87
  * persona 据此决定是否访谈。只读,不猜——画像里没有的字段不编。
88
+ * ADR-15:读经账本(未迁移 root 回退旧文件,只读)。
86
89
  */
87
90
  function renderMotivationBrief(stateRoot: string): string {
88
- try {
89
- const raw = readFileSync(join(stateRoot, 'gotry-state', 'motivation-profile.json'), 'utf-8')
90
- const p = JSON.parse(raw) as { weights?: Record<string, number>; evidence?: string[]; hard?: Record<string, unknown>; updated_at?: string }
91
- const lines: string[] = ['## 用户记忆(跨会话画像;与用户当轮说法冲突时以用户为准,更新经 gotry_motivation_save)']
92
- const weights = Object.entries(p.weights ?? {})
93
- if (weights.length) lines.push(`- 动机权重: ${weights.map(([k, v]) => `${k}=${v}`).join(', ')}(证据 ${p.evidence?.length ?? 0} 条)`)
94
- const hard = Object.entries(p.hard ?? {})
95
- if (hard.length) lines.push(`- 硬约束: ${hard.map(([k, v]) => `${k}=${String(v)}`).join(', ')}`)
96
- lines.push(`- 愿望池: 用 gotry_wish_pool_list 按条件召回(0..1),勿直接堆砌`)
97
- if (p.updated_at) lines.push(`- 更新于: ${p.updated_at}`)
98
- return weights.length || hard.length ? lines.join('\n') : ''
99
- } catch {
100
- return '' // 首访:无画像
91
+ const p = readMotivationWithFallback(stateRoot)
92
+ if (!p) return '' // 首访:无画像
93
+ const lines: string[] = ['## 用户记忆(跨会话画像;与用户当轮说法冲突时以用户为准,更新经 gotry_motivation_save)']
94
+ const weights = Object.entries(p.weights ?? {})
95
+ if (weights.length) lines.push(`- 动机权重: ${weights.map(([k, v]) => `${k}=${v}`).join(', ')}(证据 ${p.evidence?.length ?? 0} 条)`)
96
+ const hard = Object.entries(p.hard ?? {})
97
+ if (hard.length) lines.push(`- 硬约束: ${hard.map(([k, v]) => `${k}=${String(v)}`).join(', ')}`)
98
+ // 旅行时间线(memory-design P1):去过的地方不再主动推荐,除非用户点名
99
+ const trips = readTimelineTrips(stateRoot)
100
+ if (trips.length) {
101
+ lines.push(`- 去过: ${trips.map(t => `${t.destination}(${t.start.slice(0, 7)})`).join('、')}——去过的地方不再主动推荐,除非用户点名`)
102
+ }
103
+ // 同行人档案(memory-design P2):约束只进排序与行程结构建议,永不硬过滤
104
+ const companions = readCompanions(stateRoot)
105
+ if (companions.length) {
106
+ lines.push(`- 同行人: ${companions.map(c => `${c.label}(${c.brief})`).join('、')}——约束进结构与排序建议,不硬过滤;引用时带当初的原话依据`)
101
107
  }
108
+ lines.push(`- 愿望池: 用 gotry_wish_pool_list 按条件召回(0..1),勿直接堆砌`)
109
+ if (p.updated_at) lines.push(`- 更新于: ${p.updated_at}`)
110
+ return weights.length || hard.length ? lines.join('\n') : ''
111
+ }
112
+
113
+ /** 时间线摘要(brief 用):最近 3 次行程(目的地+年月);账本优先,未迁移回退文件 */
114
+ /** 同行人摘要(brief 用):label + 约束串 */
115
+ function readCompanions(stateRoot: string): Array<{ label: string; brief: string }> {
116
+ return readCompanionsWithFallback(stateRoot)
117
+ .map(c => {
118
+ const bits = [c.constraints.mobility, ...(c.constraints.health ?? []), ...(c.constraints.prefs ?? [])].filter(Boolean)
119
+ return { label: c.label, brief: bits.join('/') }
120
+ })
121
+ }
122
+
123
+ function readTimelineTrips(stateRoot: string): Array<{ destination: string; start: string }> {
124
+ return readTripsWithFallback(stateRoot)
125
+ .map(t => ({ destination: t.destination, start: t.start }))
126
+ .sort((a, b) => (a.start < b.start ? 1 : -1))
127
+ .slice(0, 3)
102
128
  }
103
129
 
104
130
  type Json = string | number | boolean | null | Json[] | { [k: string]: Json }
@@ -224,25 +250,17 @@ export function apply(ctx: Context, config: Config): void {
224
250
  // T1 接线:本工具是增量补丁语义(契约 18——模型每轮把对话新事实带进来),
225
251
  // 经 mergeProfile 守门合并进既有画像(追加不删史/幂等/权重变更须伴新证据),
226
252
  // 不再整档覆盖。首次调用(无档案)= 全量建立。
253
+ // ADR-15:守门+事件+投影在账本单事务内完成,evidence 红线拒绝即回滚,账本无痕。
227
254
  const incoming = (args.profile ?? {}) as { weights?: Record<string, number>; evidence?: string[]; hard?: Record<string, unknown> }
228
255
  if (!incoming.evidence?.length) {
229
256
  throw new Error('refusing to save a motivation profile without evidence (P0 anti-fabrication rule)')
230
257
  }
231
- const dir = await ensureStateDir(config.stateRoot)
232
- const path = join(dir, 'motivation-profile.json')
233
- let existing: { weights?: Record<string, number>; evidence?: string[]; hard?: Record<string, unknown> } | null = null
234
- try {
235
- existing = await readJson<typeof existing>(path, null)
236
- } catch { /* 首次保存:无档案 */ }
237
- const merged = mergeProfile(existing, { weights: incoming.weights, evidence: incoming.evidence, hard: incoming.hard })
238
- if (!merged) {
239
- // 幂等:补丁与现有画像完全一致,不落盘
240
- const currentJson = JSON.parse(JSON.stringify({ ...(existing ?? {}), updated_at: new Date().toISOString() })) as JsonObject
241
- return { ok: true, saved: false, path, profile: currentJson, summary: '无新内容(幂等跳过)' }
242
- }
243
- const saved = JSON.parse(JSON.stringify({ ...merged, updated_at: new Date().toISOString() })) as JsonObject
244
- await writeJson(path, saved)
245
- return { ok: true, saved: true, path, profile: saved, summary: '画像已合并落盘' }
258
+ const ledger = ensureLedger(config.stateRoot)
259
+ const res = ledger.appendMotivationPatch({ weights: incoming.weights, evidence: incoming.evidence, hard: incoming.hard })
260
+ const profileJson = JSON.parse(JSON.stringify(res.profile)) as JsonObject
261
+ return res.saved
262
+ ? { ok: true, saved: true, path: ledger.dbPath, profile: profileJson, summary: '画像已合并入账本(单事务)' }
263
+ : { ok: true, saved: false, path: ledger.dbPath, profile: profileJson, summary: '无新内容(幂等跳过)' }
246
264
  },
247
265
  presentCall: args => ({ card: 'generic', title: '保存动机画像', kind: 'edit', rawInput: args.profile }),
248
266
  }))
@@ -271,30 +289,11 @@ export function apply(ctx: Context, config: Config): void {
271
289
  },
272
290
  async execute(args: { entry: unknown }, _exec: unknown) {
273
291
  const entry = (args.entry ?? {}) as WishPoolEntryInput & { muted?: boolean }
274
- if (!entry.name || !entry.conditions) {
275
- throw new Error('wish pool entry requires name and conditions (fulfilment conditions are the whole point)')
276
- }
277
- const dir = await ensureStateDir(config.stateRoot)
278
- const path = join(dir, 'wish-pool.json')
279
- const pool = (await readJson(path, [])) as Array<Record<string, unknown>>
280
- // 同名憧憬幂等更新(刷新理由与成行条件),不重复落盘;wish_id 稳定(存量补签)
281
- const existing = pool.findIndex(e => e['name'] === entry.name)
282
- if (existing >= 0) {
283
- const prev = pool[existing] ?? {}
284
- pool[existing] = {
285
- ...prev,
286
- wish_id: prev['wish_id'] ?? `w${Date.now().toString(36)}`,
287
- reason: entry.reason ?? prev['reason'],
288
- conditions: entry.conditions,
289
- ...(entry.muted !== undefined ? { muted: entry.muted } : {}),
290
- }
291
- await writeJson(path, pool)
292
- return { ok: true, added: false, wish_id: String(pool[existing]?.['wish_id']), total: pool.length, path }
293
- }
294
- const created = { wish_id: `w${Date.now().toString(36)}`, reason: '', ...entry, added_at: new Date().toISOString() }
295
- pool.push(created)
296
- await writeJson(path, pool)
297
- return { ok: true, added: true, wish_id: created.wish_id, total: pool.length, path }
292
+ // 同名憧憬幂等更新(刷新理由与成行条件),不重复入池;wish_id 语义派生自名称,稳定可重放。
293
+ // ADR-15:conditions 红线在账本事务内校验拒绝。
294
+ const ledger = ensureLedger(config.stateRoot)
295
+ const r = ledger.appendWish({ name: entry.name, reason: entry.reason, conditions: entry.conditions, muted: entry.muted })
296
+ return { ok: true, added: r.added, wish_id: r.wish_id, total: r.total, path: ledger.dbPath }
298
297
  },
299
298
  presentCall: args => ({ card: 'generic', title: '加入「下一次出发」清单', kind: 'edit', rawInput: args.entry }),
300
299
  }))
@@ -311,7 +310,7 @@ export function apply(ctx: Context, config: Config): void {
311
310
  query: {
312
311
  type: 'json',
313
312
  required: true,
314
- description: '{ action?: "recall"|"confirm-outcome", days?: number, budgetCny?: number, month?: number, wishId?: string, attribution?: "helpful"|"harmful"|"neutral", detail?: string }',
313
+ description: '{ action?: "recall"|"confirm-outcome", days?, budgetCny?, month?, wishId?, attribution?: "helpful"|"harmful"|"neutral", detail?, tripStart?: "行程起始(确认成行时挂时间线)" }',
315
314
  },
316
315
  },
317
316
  output: {
@@ -319,44 +318,40 @@ export function apply(ctx: Context, config: Config): void {
319
318
  render: (_args, value) => [{ type: 'text', text: String((value as { summary?: string }).summary ?? '') }],
320
319
  },
321
320
  async execute(args: { query: unknown }, _exec: unknown) {
322
- const q = unwrapQuery<{ action?: string; days?: number; budgetCny?: number; month?: number; wishId?: string; attribution?: 'helpful' | 'harmful' | 'neutral'; detail?: string }>(args, 'action')
323
- const dir = await ensureStateDir(config.stateRoot)
324
- const pool = (await readJson(join(dir, 'wish-pool.json'), [])) as Array<Record<string, unknown>>
325
- const sidecarPath = join(dir, 'memory-utility.jsonl')
326
- const loadSidecar = async (): Promise<MemoryUtilityEvent[]> => {
327
- try {
328
- const raw = await readFile(sidecarPath, 'utf-8')
329
- return raw.split('\n').filter(Boolean).map(l => JSON.parse(l) as MemoryUtilityEvent)
330
- } catch { return [] } // fail-open:sidecar 缺失/损坏不阻塞召回
331
- }
332
- const saveSidecar = async (events: MemoryUtilityEvent[]) => {
333
- await writeFile(sidecarPath, events.map(e => JSON.stringify(e)).join('\n') + '\n', 'utf-8')
334
- }
321
+ const q = unwrapQuery<{ action?: string; days?: number; budgetCny?: number; month?: number; wishId?: string; attribution?: 'helpful' | 'harmful' | 'neutral'; detail?: string; tripStart?: string }>(args, 'action')
322
+ const ledger = ensureLedger(config.stateRoot)
335
323
  const now = new Date().toISOString()
336
324
  if (q.action === 'confirm-outcome') {
337
325
  if (!q.wishId || !q.attribution) {
338
326
  return { ok: false, summary: 'confirm-outcome 需要 wishId + attribution(helpful|harmful|neutral)' } as never
339
327
  }
340
- const events = await loadSidecar()
341
- const { events: next, appended } = appendEvent(events, {
342
- wish_id: q.wishId, kind: 'verified_outcome', ts: now,
343
- ctx: 'gotry_wish_pool_list.confirm', detail: q.detail, attribution: q.attribution,
344
- })
345
- if (appended) await saveSidecar(next)
346
- return { ok: true, recorded: appended, wish_id: q.wishId, status: q.attribution } as never
328
+ // 成行确认 = 效用事件 + 可选时间线行程,账本单事务(ADR-15:原两文件写在崩溃时分叉)
329
+ let tripInput: { destination: string; start: string; end?: string; companions?: string[]; source: 'wish-confirmed'; evidence: string } | undefined
330
+ const wish = ledger.readWishPool().find(e => String(e.wish_id) === q.wishId) as { name?: unknown } | undefined
331
+ if (q.tripStart && wish?.name) {
332
+ const anchor = buildTimeAnchor(new Date())
333
+ const start = resolveTimelineDate(q.tripStart, anchor) ?? (/^\d{4}-\d{2}-\d{2}$/.test(q.tripStart) ? q.tripStart : undefined)
334
+ if (start) {
335
+ tripInput = {
336
+ destination: String(wish.name), start, companions: undefined,
337
+ source: 'wish-confirmed', evidence: `wish ${q.wishId} confirm-outcome(${q.attribution})`,
338
+ }
339
+ }
340
+ }
341
+ const r = ledger.confirmOutcome({ wishId: q.wishId, attribution: q.attribution, detail: q.detail, trip: tripInput })
342
+ return { ok: true, recorded: r.recorded, wish_id: q.wishId, status: q.attribution, ...(r.trip ? { trip: r.trip } : {}) } as never
347
343
  }
348
344
  // recall:0..1 条件匹配(判定归 wish-pool 纯函数),muted 永不召回,无命中不硬推
349
- const candidates = pool.filter(e => !e['muted'] && typeof e['wish_id'] === 'string')
345
+ const pool = ledger.readWishPool()
346
+ const candidates = pool.filter(e => !e.muted && typeof e.wish_id === 'string')
350
347
  const month = q.month ?? new Date().getMonth() + 1
351
348
  const match = pickNudgeWish(candidates as WishPoolEntry[], { days: q.days, budgetCny: q.budgetCny, month })
352
349
  if (!match) {
353
350
  return { ok: true, suggestion: null, summary: `无可成行的憧憬匹配当前窗口(${candidates.length} 条在册,0..1 纪律:不硬推)` } as never
354
351
  }
355
- const events = await loadSidecar()
356
- const { events: next, appended } = appendEvent(events, {
352
+ const { events: next } = ledger.appendUtilityEvent({
357
353
  wish_id: match.wishId, kind: 'recalled', ts: now, ctx: 'gotry_wish_pool_list.recall',
358
354
  })
359
- if (appended) await saveSidecar(next)
360
355
  const utility = projectUtility(next)[match.wishId]
361
356
  return {
362
357
  ok: true,
@@ -368,6 +363,78 @@ export function apply(ctx: Context, config: Config): void {
368
363
  presentCall: args => ({ card: 'generic', title: '「下一次出发」召回', kind: 'search', rawInput: args.query }),
369
364
  }))
370
365
 
366
+ registerGuarded(defineTool({
367
+ name: 'gotry_companion_save',
368
+ description:
369
+ 'Save/update a travel companion profile (memory-design M5 layer): constraints that shape itinerary STRUCTURE and ranking — '
370
+ + 'never a hard filter (「爸爸65轻度高血压」→ 不排高海拔/控制步行量;「晕车」→ 优先火车/备提示). '
371
+ + 'evidence MUST be the user\'s verbatim words (append-only, traceable). '
372
+ + 'NEGATIVE LIST: passport/ID/phone numbers are rejected on sight — such fields never enter storage.',
373
+ parameters: {
374
+ companion: {
375
+ type: 'json',
376
+ required: true,
377
+ description: '{ label: "爸爸", constraints: { mobility?: "步行≤4h", health?: ["轻度高血压"], prefs?: ["怕吵"] }, evidence: "<用户原话>" }',
378
+ },
379
+ },
380
+ output: {
381
+ schema: { type: 'json' },
382
+ render: (_args, value) => [{ type: 'text', text: String((value as { summary?: string }).summary ?? '') }],
383
+ },
384
+ async execute(args: { companion: unknown }, _exec: unknown) {
385
+ const c = (args.companion ?? {}) as { label?: string; constraints?: { mobility?: string; health?: string[]; prefs?: string[] }; evidence?: string }
386
+ if (!c.evidence) return { ok: false, summary: 'evidence 必填(用户原话,溯源 P0)' } as never
387
+ // ADR-15:负面清单守卫(upsertCompanion)在账本事务内执行,拒收即回滚
388
+ const ledger = ensureLedger(config.stateRoot)
389
+ const res = ledger.appendCompanion({
390
+ label: String(c.label ?? ''),
391
+ constraints: { mobility: c.constraints?.mobility, health: c.constraints?.health, prefs: c.constraints?.prefs },
392
+ evidence: c.evidence,
393
+ })
394
+ if (!res.appended && res.reason) return { ok: false, summary: res.reason } as never
395
+ return { ok: true, appended: res.appended, companion_id: res.companionId, total: res.total, summary: res.appended ? `同行人已保存:${res.companionId}` : `无新内容(幂等跳过):${res.companionId}` } as never
396
+ },
397
+ presentCall: args => ({ card: 'generic', title: '保存同行人', kind: 'edit', rawInput: args.companion }),
398
+ }))
399
+
400
+ registerGuarded(defineTool({
401
+ name: 'gotry_trip_log',
402
+ description:
403
+ 'Record a PAST trip into the travel timeline (memory-design M4 layer). Use when the user mentions a trip they already took '
404
+ + '(「去年国庆去了大理」) — dates resolve via the time anchor (词表外日期会拒绝,请用户给绝对日期), evidence MUST be the user\'s verbatim words. '
405
+ + 'Append-only and idempotent (same destination+start+source = same trip); overlapping same-destination trips are rejected for human adjudication. '
406
+ + 'Past trips power origin resolution and 「去过不再推」 ranking — never a hard filter.',
407
+ parameters: {
408
+ query: {
409
+ type: 'json',
410
+ required: true,
411
+ description: '{ destination: "大理", start: "2025-10-01 或绝对日期表达", end?: 同上, companions?: ["爸爸"], evidence: "<用户原话>" }',
412
+ },
413
+ },
414
+ output: {
415
+ schema: { type: 'json' },
416
+ render: (_args, value) => [{ type: 'text', text: String((value as { summary?: string }).summary ?? '') }],
417
+ },
418
+ async execute(args: { query: unknown }, _exec: unknown) {
419
+ const q = unwrapQuery<{ destination?: string; start?: string; end?: string; companions?: string[]; evidence?: string }>(args, 'destination')
420
+ if (!q.destination) return { ok: false, summary: 'destination 必填' } as never
421
+ if (!q.evidence) return { ok: false, summary: 'evidence 必填(用户原话,溯源 P0)' } as never
422
+ const anchor = buildTimeAnchor(new Date())
423
+ const start = resolveTimelineDate(q.start ?? '', anchor) ?? (/^\d{4}-\d{2}-\d{2}$/.test(q.start ?? '') ? q.start : undefined)
424
+ if (!start) return { ok: false, summary: `start 无法解析为绝对日期(${q.start ?? '缺'})——请向用户要具体日期,不猜` } as never
425
+ const end = q.end ? (resolveTimelineDate(q.end, anchor) ?? (/^\d{4}-\d{2}-\d{2}$/.test(q.end) ? q.end : undefined)) : undefined
426
+ // ADR-15:appendTrip 守门(必填/绝对日期/重叠冲突即停)在账本事务内执行
427
+ const ledger = ensureLedger(config.stateRoot)
428
+ const res = ledger.appendTripEvent({
429
+ destination: q.destination.trim(), start, end, companions: q.companions,
430
+ source: 'user-verbatim', evidence: q.evidence,
431
+ })
432
+ if (!res.appended && res.reason) return { ok: false, summary: res.reason } as never
433
+ return { ok: true, appended: res.appended, trip_id: res.tripId, total: res.total, summary: res.appended ? `已入时间线:${q.destination} @ ${start}` : `已存在(幂等跳过):${res.tripId}` } as never
434
+ },
435
+ presentCall: args => ({ card: 'generic', title: '记录旅行', kind: 'edit', rawInput: args.query }),
436
+ }))
437
+
371
438
  registerGuarded(defineTool({
372
439
  name: 'gotry_hotel_search',
373
440
  description:
@@ -573,6 +640,72 @@ export function apply(ctx: Context, config: Config): void {
573
640
  presentCall: args => ({ card: 'generic', title: `飞行校验:${String((args.query as { callsign?: string })?.callsign ?? '')}`, kind: 'fetch', rawInput: args.query }),
574
641
  }))
575
642
 
643
+ registerGuarded(defineTool({
644
+ name: 'gotry_flyai_search',
645
+ description:
646
+ 'Live flight/train search via Fliggy official FlyAI channel (read-only, no key, booking only via jumpUrl by the human). '
647
+ + 'PRIMARY source for real schedules & prices (RFC user-session-data-rfc). Input: kind=flight|train, from/to city names (Chinese), date YYYY-MM-DD. '
648
+ + 'Evidence [实时API:flyai@ts]. Cross-validate expensive claims against gotry_session_search when it matters.',
649
+ parameters: {
650
+ query: { type: 'json', required: true, description: '{ kind: "flight"|"train", from: "上海", to: "丽江", date: "2026-10-01" }' },
651
+ },
652
+ output: { schema: { type: 'json' }, render: (_a, v) => [{ type: 'text', text: String((v as { summary?: string }).summary ?? JSON.stringify(v).slice(0, 600)) }] },
653
+ async execute(args: { query: unknown }, _exec: unknown) {
654
+ const q = unwrapQuery<{ kind?: string; from?: string; to?: string; date?: string }>(args, 'from')
655
+ const kind = q.kind === 'train' ? 'train' : 'flight'
656
+ if (!q.from || !q.to || !q.date) {
657
+ return { ok: false, summary: '需要 from/to(中文城市名)与 date(YYYY-MM-DD)' } as const
658
+ }
659
+ const r = await flyaiSearch({ kind, origin: q.from, destination: q.to, depDate: q.date })
660
+ 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}`)
661
+ const summary = r.verdict === 'hit'
662
+ ? `${q.from}→${q.to} ${q.date} ${kind === 'flight' ? '机票' : '火车票'}(飞猪官方只读)前 ${top.length} 条:\n${top.join('\n')}\n${r.evidence}`
663
+ : `${q.from}→${q.to} ${q.date} 无结果或失败:${r.error ?? 'miss'} ${r.evidence}`
664
+ return JSON.parse(JSON.stringify({ ...r, kind, summary })) as Record<string, never>
665
+ },
666
+ presentCall: args => ({ card: 'generic', title: `官方检索:${String((args.query as { kind?: string })?.kind ?? 'flight')}`, kind: 'fetch', rawInput: args.query }),
667
+ presentResult: (args, value) => {
668
+ const r = value as { ok?: boolean; options?: unknown[] }
669
+ return { card: 'generic', title: `飞猪检索:${r.ok && (r.options?.length ?? 0) > 0 ? `${r.options!.length} 条` : '降级'}`, content: [{ type: 'text', text: String((value as { summary?: string }).summary ?? '') }] }
670
+ },
671
+ }))
672
+
673
+ registerGuarded(defineTool({
674
+ name: 'gotry_session_search',
675
+ description:
676
+ 'Cross-validation search on the user\'s OWN logged-in browser session (dedicated persistent profile, ReadGuard = physically read-only: '
677
+ + 'write requests are aborted at network layer; agent NEVER touches credentials/captcha; on captcha it stops and returns challenged). '
678
+ + 'Currently ctrip-flight: sniffs the site search API for structured options. Evidence [会话:ctrip-flight@ts]. '
679
+ + 'verdict needs-login = run scripts/session-login.ts once with the human logging in. Rate-limited (≥30s between same-site calls). '
680
+ + 'Use to verify/double-check prices from gotry_flyai_search on the same itinerary (split by 直达/中转).',
681
+ parameters: {
682
+ query: { type: 'json', required: true, description: '{ from: "上海", to: "丽江", date: "2026-10-01" }' },
683
+ },
684
+ output: { schema: { type: 'json' }, render: (_a, v) => [{ type: 'text', text: String((v as { summary?: string }).summary ?? JSON.stringify(v).slice(0, 600)) }] },
685
+ async execute(args: { query: unknown }, _exec: unknown) {
686
+ const q = unwrapQuery<{ from?: string; to?: string; date?: string }>(args, 'from')
687
+ if (!q.from || !q.to || !q.date) {
688
+ return { ok: false, summary: '需要 from/to(中文城市名,词表内)与 date(YYYY-MM-DD)' } as const
689
+ }
690
+ const r = await sessionFlightSearch({
691
+ from: q.from, to: q.to, date: q.date,
692
+ // ADR-15 收尾:ReadGuard 审计在生产工具路径同样落盘(此前仅测试传隔离 stateRoot 才有 JSONL)
693
+ auditPath: join(config.stateRoot ?? '.', 'gotry-state', 'session-incidents.jsonl'),
694
+ })
695
+ 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}`)
696
+ const summary = r.verdict === 'hit'
697
+ ? `${q.from}→${q.to} ${q.date} 会话检索(携程,用户本人登录态)前 ${top.length} 条:\n${top.join('\n')}\n${r.evidence}`
698
+ : `会话检索未取回(${r.verdict}):${r.error ?? ''} ${r.evidence}`
699
+ return JSON.parse(JSON.stringify({ ...r, summary })) as Record<string, never>
700
+ },
701
+ presentCall: args => ({ card: 'generic', title: `会话检索:${String((args.query as { from?: string })?.from ?? '')}`, kind: 'fetch', rawInput: args.query }),
702
+ presentResult: (_args, value) => {
703
+ const r = value as { verdict?: string; options?: unknown[] }
704
+ const label = r.verdict === 'hit' ? `会话 ${r.options!.length} 条` : (r.verdict === 'needs-login' ? '需登录' : r.verdict ?? '降级')
705
+ return { card: 'generic', title: `会话检索:${label}`, content: [{ type: 'text', text: String((value as { summary?: string }).summary ?? '') }] }
706
+ },
707
+ }))
708
+
576
709
  registerGuarded(defineTool({
577
710
  name: 'gotry_anything_search',
578
711
  description:
package/ts/src/loop.ts CHANGED
@@ -292,42 +292,82 @@ export async function runTurn(
292
292
  return { reply: parts.join('\n\n'), state }
293
293
  }
294
294
 
295
- // ---- 异步工单持久化(S5 编排半段):真正的「一小时后」必须跨进程存续 ----
296
- // 请求时落盘(ticket+state 快照);任意后续进程(如 loopx 驱动的 tick)执行
297
- // async-collect 加载、求解、写回交付物。状态目录属用户数据(红线 6)。
295
+ // ---- 异步工单持久化(S5 编排半段 → ADR-15 durable 工单):真正的「一小时后」必须跨进程存续 ----
296
+ // 权威 = 账本 workflow_runs/workflow_steps(单事务;崩溃后 done 步骤不重执行);
297
+ // {id}.json / {id}.deliverable.md 降级为视图(AGENTS.md 清扫合同 + 人工检视),
298
+ // tmp+rename 原子写——此前裸 writeFile 的非原子缺口(RFC §1.1)就此关闭。
298
299
 
299
300
  import { join } from 'node:path'
300
- import { mkdir } from 'node:fs/promises'
301
- import { readFile, writeFile } from 'node:fs/promises'
301
+ import { mkdir, rename, writeFile, readFile } from 'node:fs/promises'
302
+ import { ensureLedger, openLedgerIfExists, type StateLedger } from './state-ledger.ts'
302
303
 
303
- const ASYNC_DIR = 'gotry-state/async'
304
+ async function asyncDir(stateRoot: string): Promise<string> {
305
+ const root = stateRoot === '.' ? process.cwd() : stateRoot
306
+ const dir = join(root, 'gotry-state', 'async')
307
+ await mkdir(dir, { recursive: true })
308
+ return dir
309
+ }
304
310
 
305
- async function asyncPath(name: string): Promise<string> {
306
- await mkdir(ASYNC_DIR, { recursive: true })
307
- return join(ASYNC_DIR, name)
311
+ async function atomicWrite(path: string, text: string): Promise<void> {
312
+ const tmp = `${path}.tmp`
313
+ await writeFile(tmp, text, 'utf-8')
314
+ await rename(tmp, path)
308
315
  }
309
316
 
310
- export async function persistAsyncTicket(ticket: AsyncTicket, state: TripState): Promise<string> {
311
- const p = await asyncPath(`${ticket.id}.json`)
312
- await writeFile(p, JSON.stringify({ ticket, state }, null, 2), 'utf-8')
317
+ export async function persistAsyncTicket(ticket: AsyncTicket, state: TripState, stateRoot = '.'): Promise<string> {
318
+ const ledger = ensureLedger(stateRoot)
319
+ ledger.createWorkflowRun({ id: ticket.id, goal: ticket.objective, ticket, state })
320
+ const dir = await asyncDir(stateRoot)
321
+ const p = join(dir, `${ticket.id}.json`)
322
+ await atomicWrite(p, JSON.stringify({ ticket, state }, null, 2))
313
323
  return p
314
324
  }
315
325
 
316
- export async function loadAsyncTicket(ticketId: string): Promise<{ ticket: AsyncTicket; state: TripState } | null> {
326
+ export async function loadAsyncTicket(ticketId: string, stateRoot = '.'): Promise<{ ticket: AsyncTicket; state: TripState } | null> {
327
+ // 账本优先;无账本的 root 回退旧 json 文件(只读,兼容存量已交付工单)
328
+ const ledger = openLedgerIfExists(stateRoot)
329
+ const run = ledger?.getWorkflowRun(ticketId)
330
+ if (run) {
331
+ return {
332
+ ticket: JSON.parse(run.ticket_json) as AsyncTicket,
333
+ state: JSON.parse(run.state_json) as TripState,
334
+ }
335
+ }
317
336
  try {
318
- const p = await asyncPath(`${ticketId}.json`)
337
+ const p = join(await asyncDir(stateRoot), `${ticketId}.json`)
319
338
  return JSON.parse(await readFile(p, 'utf-8')) as { ticket: AsyncTicket; state: TripState }
320
339
  } catch {
321
340
  return null
322
341
  }
323
342
  }
324
343
 
325
- export async function settleAsyncTicket(ticketId: string, reply: string): Promise<string> {
326
- const p = await asyncPath(`${ticketId}.deliverable.md`)
327
- await writeFile(p, reply, 'utf-8')
344
+ export async function settleAsyncTicket(ticketId: string, reply: string, stateRoot = '.'): Promise<string> {
345
+ openLedgerIfExists(stateRoot)?.settleWorkflowRun(ticketId, reply)
346
+ const dir = await asyncDir(stateRoot)
347
+ const p = join(dir, `${ticketId}.deliverable.md`)
348
+ await atomicWrite(p, reply)
328
349
  return p
329
350
  }
330
351
 
352
+ /**
353
+ * 可日志化 solve 端口(ADR-15 步骤日志,intent-before-execute):
354
+ * 先记 intent 再执行,done 后结果落账本——任意进程恢复时 done 的步骤直接复用
355
+ * 结果不重执行(exactly-once:LLM/求解调用不重复花钱),intent 悬挂的重试。
356
+ */
357
+ export function makeJournaledSolvePort(ledger: StateLedger, runId: string, solve: SolvePort, opts?: { onRealSolve?: () => void }): SolvePort {
358
+ return async spec => {
359
+ const step = ledger.getWorkflowStep(runId, 'solve')
360
+ if (step?.status === 'done' && step.result) {
361
+ return JSON.parse(step.result) as Awaited<ReturnType<SolvePort>>
362
+ }
363
+ ledger.markStepIntent(runId, 'solve')
364
+ opts?.onRealSolve?.()
365
+ const r = await solve(spec)
366
+ ledger.markStepDone(runId, 'solve', r)
367
+ return r
368
+ }
369
+ }
370
+
331
371
  /**
332
372
  * probePoi:从 user msg 探测"查 POI/酒店" 信号,返回关键词。
333
373
  * 触发的不是关键词,是**结构**;关键词抓取有方向性——
package/ts/src/unified.ts CHANGED
@@ -28,6 +28,8 @@ export interface MoveSpecTS {
28
28
  destTransferMin: number
29
29
  redEye?: boolean
30
30
  redEyeDurationMin?: number
31
+ /** D-6:红眼航段落地后接驳(机场→住处/办公室)的乘车补眠时长(分钟);机上已算,落地接驳未算 */
32
+ groundRecoveryMin?: number
31
33
  /** D-5:目的地相对出发地的时差(KMG-BKK=+60,DXB-SZX=-240) */
32
34
  tzOffsetMin?: number
33
35
  /** M-1:出发地 UTC 偏移(工作窗口换算用) */
@@ -227,7 +229,10 @@ function evaluateOptionMove(segId: string, mv: MoveSpecTS): LegReport & { d2d_mi
227
229
  let energy: number
228
230
  if (mv.redEye && (mv.redEyeDurationMin ?? 0) > 0) {
229
231
  const sleepH = ((mv.redEyeDurationMin ?? 0) - 60) / 60
230
- energy = Math.max(30, Math.min(75, 30 + 8 * sleepH))
232
+ // D-6 校准:机上睡眠上限 75;落地接驳(destTransferMin)乘车补眠回血(1h≈+5%,上限 80)
233
+ const groundH = (mv.groundRecoveryMin ?? mv.destTransferMin ?? 0) / 60
234
+ const recovery = Math.min(5, 5 * groundH)
235
+ energy = Math.max(30, Math.min(75 + recovery, 80, 30 + 8 * sleepH + recovery))
231
236
  } else {
232
237
  energy = 100 - 2 * 8
233
238
  if (wake < 5 * 60) energy -= 30