@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
package/dist/src/index.js CHANGED
@@ -1,18 +1,17 @@
1
1
  import { join } from 'node:path';
2
- import { readFile, writeFile } from 'node:fs/promises';
3
- import { readFileSync } from 'node:fs';
4
2
  import z from '@deepseek-ai/schemastery';
5
3
  import { defineTool } from '@deepseek-ai/dsh-tools';
6
- import { ensureStateDir, readJson, recordLatency, writeJson } from './bridge.js';
4
+ import { ensureStateDir, recordLatency } from './bridge.js';
7
5
  import { segmentsFromCandidate, solveChoiceSegment } from './unified.js';
8
6
  import { checkConnectivity } from '../scripts/skeleton-check.js';
9
7
  import { parseCandidate, parseRequest } from './model.js';
10
8
  import { searchHotels as hbcliSearchHotels } from '../capabilities/hbcli.js';
11
9
  import { installProcessGuards, guardToolExecute } from '../capabilities/incident-log.js';
12
10
  import { interpretArgs } from './tool-packet.js';
13
- import { appendEvent, projectUtility } from './memory-utility.js';
11
+ import { projectUtility } from './memory-utility.js';
14
12
  import { pickNudgeWish } from './wish-pool.js';
15
- import { mergeProfile } from './memory-capture.js';
13
+ import { resolveTimelineDate } from './travel-timeline.js';
14
+ import { ensureLedger, readCompanionsWithFallback, readMotivationWithFallback, readTripsWithFallback, readWishPoolWithFallback } from './state-ledger.js';
16
15
  import { buildTimeAnchor } from './time-anchor.js';
17
16
  import { resolveSlotDate } from './slot-spec.js';
18
17
  import { geocodePlace, getForecast, getClimate, wmoLabel } from '../capabilities/weather.js';
@@ -20,6 +19,8 @@ import { verifyFlight } from '../capabilities/opensky.js';
20
19
  import { anythingSearch } from '../capabilities/anything.js';
21
20
  import { readUrl, reach, reachStatus } from '../capabilities/agent-reach.js';
22
21
  import { videoSubtitle, githubSearch } from '../capabilities/agent-reach-deep.js';
22
+ import { flyaiSearch } from '../capabilities/flyai.js';
23
+ import { sessionFlightSearch } from '../capabilities/session-search.js';
23
24
  export const name = 'gotry-tools';
24
25
  export const inject = [
25
26
  'tools',
@@ -32,22 +33,45 @@ export const Config = z.object({
32
33
  });
33
34
  const unwrapQuery = interpretArgs;
34
35
  function renderMotivationBrief(stateRoot) {
35
- try {
36
- const raw = readFileSync(join(stateRoot, 'gotry-state', 'motivation-profile.json'), 'utf-8');
37
- const p = JSON.parse(raw);
38
- const lines = [
39
- '## 用户记忆(跨会话画像;与用户当轮说法冲突时以用户为准,更新经 gotry_motivation_save)'
40
- ];
41
- const weights = Object.entries(p.weights ?? {});
42
- if (weights.length) lines.push(`- 动机权重: ${weights.map(([k, v])=>`${k}=${v}`).join(', ')}(证据 ${p.evidence?.length ?? 0} 条)`);
43
- const hard = Object.entries(p.hard ?? {});
44
- if (hard.length) lines.push(`- 硬约束: ${hard.map(([k, v])=>`${k}=${String(v)}`).join(', ')}`);
45
- lines.push(`- 愿望池: 用 gotry_wish_pool_list 按条件召回(0..1),勿直接堆砌`);
46
- if (p.updated_at) lines.push(`- 更新于: ${p.updated_at}`);
47
- return weights.length || hard.length ? lines.join('\n') : '';
48
- } catch {
49
- return '';
36
+ const p = readMotivationWithFallback(stateRoot);
37
+ if (!p) return '';
38
+ const lines = [
39
+ '## 用户记忆(跨会话画像;与用户当轮说法冲突时以用户为准,更新经 gotry_motivation_save)'
40
+ ];
41
+ const weights = Object.entries(p.weights ?? {});
42
+ if (weights.length) lines.push(`- 动机权重: ${weights.map(([k, v])=>`${k}=${v}`).join(', ')}(证据 ${p.evidence?.length ?? 0} 条)`);
43
+ const hard = Object.entries(p.hard ?? {});
44
+ if (hard.length) lines.push(`- 硬约束: ${hard.map(([k, v])=>`${k}=${String(v)}`).join(', ')}`);
45
+ const trips = readTimelineTrips(stateRoot);
46
+ if (trips.length) {
47
+ lines.push(`- 去过: ${trips.map((t)=>`${t.destination}(${t.start.slice(0, 7)})`).join('、')}——去过的地方不再主动推荐,除非用户点名`);
48
+ }
49
+ const companions = readCompanions(stateRoot);
50
+ if (companions.length) {
51
+ lines.push(`- 同行人: ${companions.map((c)=>`${c.label}(${c.brief})`).join('、')}——约束进结构与排序建议,不硬过滤;引用时带当初的原话依据`);
50
52
  }
53
+ lines.push(`- 愿望池: 用 gotry_wish_pool_list 按条件召回(0..1),勿直接堆砌`);
54
+ if (p.updated_at) lines.push(`- 更新于: ${p.updated_at}`);
55
+ return weights.length || hard.length ? lines.join('\n') : '';
56
+ }
57
+ function readCompanions(stateRoot) {
58
+ return readCompanionsWithFallback(stateRoot).map((c)=>{
59
+ const bits = [
60
+ c.constraints.mobility,
61
+ ...c.constraints.health ?? [],
62
+ ...c.constraints.prefs ?? []
63
+ ].filter(Boolean);
64
+ return {
65
+ label: c.label,
66
+ brief: bits.join('/')
67
+ };
68
+ });
69
+ }
70
+ function readTimelineTrips(stateRoot) {
71
+ return readTripsWithFallback(stateRoot).map((t)=>({
72
+ destination: t.destination,
73
+ start: t.start
74
+ })).sort((a, b)=>a.start < b.start ? 1 : -1).slice(0, 3);
51
75
  }
52
76
  export function apply(ctx, config) {
53
77
  const sp = ctx['systemPrompt'];
@@ -169,41 +193,25 @@ export function apply(ctx, config) {
169
193
  if (!incoming.evidence?.length) {
170
194
  throw new Error('refusing to save a motivation profile without evidence (P0 anti-fabrication rule)');
171
195
  }
172
- const dir = await ensureStateDir(config.stateRoot);
173
- const path = join(dir, 'motivation-profile.json');
174
- let existing = null;
175
- try {
176
- existing = await readJson(path, null);
177
- } catch {}
178
- const merged = mergeProfile(existing, {
196
+ const ledger = ensureLedger(config.stateRoot);
197
+ const res = ledger.appendMotivationPatch({
179
198
  weights: incoming.weights,
180
199
  evidence: incoming.evidence,
181
200
  hard: incoming.hard
182
201
  });
183
- if (!merged) {
184
- const currentJson = JSON.parse(JSON.stringify({
185
- ...existing ?? {},
186
- updated_at: new Date().toISOString()
187
- }));
188
- return {
189
- ok: true,
190
- saved: false,
191
- path,
192
- profile: currentJson,
193
- summary: '无新内容(幂等跳过)'
194
- };
195
- }
196
- const saved = JSON.parse(JSON.stringify({
197
- ...merged,
198
- updated_at: new Date().toISOString()
199
- }));
200
- await writeJson(path, saved);
201
- return {
202
+ const profileJson = JSON.parse(JSON.stringify(res.profile));
203
+ return res.saved ? {
202
204
  ok: true,
203
205
  saved: true,
204
- path,
205
- profile: saved,
206
- summary: '画像已合并落盘'
206
+ path: ledger.dbPath,
207
+ profile: profileJson,
208
+ summary: '画像已合并入账本(单事务)'
209
+ } : {
210
+ ok: true,
211
+ saved: false,
212
+ path: ledger.dbPath,
213
+ profile: profileJson,
214
+ summary: '无新内容(幂等跳过)'
207
215
  };
208
216
  },
209
217
  presentCall: (args)=>({
@@ -239,47 +247,19 @@ export function apply(ctx, config) {
239
247
  },
240
248
  async execute (args, _exec) {
241
249
  const entry = args.entry ?? {};
242
- if (!entry.name || !entry.conditions) {
243
- throw new Error('wish pool entry requires name and conditions (fulfilment conditions are the whole point)');
244
- }
245
- const dir = await ensureStateDir(config.stateRoot);
246
- const path = join(dir, 'wish-pool.json');
247
- const pool = await readJson(path, []);
248
- const existing = pool.findIndex((e)=>e['name'] === entry.name);
249
- if (existing >= 0) {
250
- const prev = pool[existing] ?? {};
251
- pool[existing] = {
252
- ...prev,
253
- wish_id: prev['wish_id'] ?? `w${Date.now().toString(36)}`,
254
- reason: entry.reason ?? prev['reason'],
255
- conditions: entry.conditions,
256
- ...entry.muted !== undefined ? {
257
- muted: entry.muted
258
- } : {}
259
- };
260
- await writeJson(path, pool);
261
- return {
262
- ok: true,
263
- added: false,
264
- wish_id: String(pool[existing]?.['wish_id']),
265
- total: pool.length,
266
- path
267
- };
268
- }
269
- const created = {
270
- wish_id: `w${Date.now().toString(36)}`,
271
- reason: '',
272
- ...entry,
273
- added_at: new Date().toISOString()
274
- };
275
- pool.push(created);
276
- await writeJson(path, pool);
250
+ const ledger = ensureLedger(config.stateRoot);
251
+ const r = ledger.appendWish({
252
+ name: entry.name,
253
+ reason: entry.reason,
254
+ conditions: entry.conditions,
255
+ muted: entry.muted
256
+ });
277
257
  return {
278
258
  ok: true,
279
- added: true,
280
- wish_id: created.wish_id,
281
- total: pool.length,
282
- path
259
+ added: r.added,
260
+ wish_id: r.wish_id,
261
+ total: r.total,
262
+ path: ledger.dbPath
283
263
  };
284
264
  },
285
265
  presentCall: (args)=>({
@@ -296,7 +276,7 @@ export function apply(ctx, config) {
296
276
  query: {
297
277
  type: 'json',
298
278
  required: true,
299
- description: '{ action?: "recall"|"confirm-outcome", days?: number, budgetCny?: number, month?: number, wishId?: string, attribution?: "helpful"|"harmful"|"neutral", detail?: string }'
279
+ description: '{ action?: "recall"|"confirm-outcome", days?, budgetCny?, month?, wishId?, attribution?: "helpful"|"harmful"|"neutral", detail?, tripStart?: "行程起始(确认成行时挂时间线)" }'
300
280
  }
301
281
  },
302
282
  output: {
@@ -312,20 +292,7 @@ export function apply(ctx, config) {
312
292
  },
313
293
  async execute (args, _exec) {
314
294
  const q = unwrapQuery(args, 'action');
315
- const dir = await ensureStateDir(config.stateRoot);
316
- const pool = await readJson(join(dir, 'wish-pool.json'), []);
317
- const sidecarPath = join(dir, 'memory-utility.jsonl');
318
- const loadSidecar = async ()=>{
319
- try {
320
- const raw = await readFile(sidecarPath, 'utf-8');
321
- return raw.split('\n').filter(Boolean).map((l)=>JSON.parse(l));
322
- } catch {
323
- return [];
324
- }
325
- };
326
- const saveSidecar = async (events)=>{
327
- await writeFile(sidecarPath, events.map((e)=>JSON.stringify(e)).join('\n') + '\n', 'utf-8');
328
- };
295
+ const ledger = ensureLedger(config.stateRoot);
329
296
  const now = new Date().toISOString();
330
297
  if (q.action === 'confirm-outcome') {
331
298
  if (!q.wishId || !q.attribution) {
@@ -334,24 +301,39 @@ export function apply(ctx, config) {
334
301
  summary: 'confirm-outcome 需要 wishId + attribution(helpful|harmful|neutral)'
335
302
  };
336
303
  }
337
- const events = await loadSidecar();
338
- const { events: next, appended } = appendEvent(events, {
339
- wish_id: q.wishId,
340
- kind: 'verified_outcome',
341
- ts: now,
342
- ctx: 'gotry_wish_pool_list.confirm',
304
+ let tripInput;
305
+ const wish = ledger.readWishPool().find((e)=>String(e.wish_id) === q.wishId);
306
+ if (q.tripStart && wish?.name) {
307
+ const anchor = buildTimeAnchor(new Date());
308
+ const start = resolveTimelineDate(q.tripStart, anchor) ?? (/^\d{4}-\d{2}-\d{2}$/.test(q.tripStart) ? q.tripStart : undefined);
309
+ if (start) {
310
+ tripInput = {
311
+ destination: String(wish.name),
312
+ start,
313
+ companions: undefined,
314
+ source: 'wish-confirmed',
315
+ evidence: `wish ${q.wishId} confirm-outcome(${q.attribution})`
316
+ };
317
+ }
318
+ }
319
+ const r = ledger.confirmOutcome({
320
+ wishId: q.wishId,
321
+ attribution: q.attribution,
343
322
  detail: q.detail,
344
- attribution: q.attribution
323
+ trip: tripInput
345
324
  });
346
- if (appended) await saveSidecar(next);
347
325
  return {
348
326
  ok: true,
349
- recorded: appended,
327
+ recorded: r.recorded,
350
328
  wish_id: q.wishId,
351
- status: q.attribution
329
+ status: q.attribution,
330
+ ...r.trip ? {
331
+ trip: r.trip
332
+ } : {}
352
333
  };
353
334
  }
354
- const candidates = pool.filter((e)=>!e['muted'] && typeof e['wish_id'] === 'string');
335
+ const pool = ledger.readWishPool();
336
+ const candidates = pool.filter((e)=>!e.muted && typeof e.wish_id === 'string');
355
337
  const month = q.month ?? new Date().getMonth() + 1;
356
338
  const match = pickNudgeWish(candidates, {
357
339
  days: q.days,
@@ -365,14 +347,12 @@ export function apply(ctx, config) {
365
347
  summary: `无可成行的憧憬匹配当前窗口(${candidates.length} 条在册,0..1 纪律:不硬推)`
366
348
  };
367
349
  }
368
- const events = await loadSidecar();
369
- const { events: next, appended } = appendEvent(events, {
350
+ const { events: next } = ledger.appendUtilityEvent({
370
351
  wish_id: match.wishId,
371
352
  kind: 'recalled',
372
353
  ts: now,
373
354
  ctx: 'gotry_wish_pool_list.recall'
374
355
  });
375
- if (appended) await saveSidecar(next);
376
356
  const utility = projectUtility(next)[match.wishId];
377
357
  return {
378
358
  ok: true,
@@ -398,6 +378,128 @@ export function apply(ctx, config) {
398
378
  rawInput: args.query
399
379
  })
400
380
  }));
381
+ registerGuarded(defineTool({
382
+ name: 'gotry_companion_save',
383
+ description: 'Save/update a travel companion profile (memory-design M5 layer): constraints that shape itinerary STRUCTURE and ranking — ' + 'never a hard filter (「爸爸65轻度高血压」→ 不排高海拔/控制步行量;「晕车」→ 优先火车/备提示). ' + 'evidence MUST be the user\'s verbatim words (append-only, traceable). ' + 'NEGATIVE LIST: passport/ID/phone numbers are rejected on sight — such fields never enter storage.',
384
+ parameters: {
385
+ companion: {
386
+ type: 'json',
387
+ required: true,
388
+ description: '{ label: "爸爸", constraints: { mobility?: "步行≤4h", health?: ["轻度高血压"], prefs?: ["怕吵"] }, evidence: "<用户原话>" }'
389
+ }
390
+ },
391
+ output: {
392
+ schema: {
393
+ type: 'json'
394
+ },
395
+ render: (_args, value)=>[
396
+ {
397
+ type: 'text',
398
+ text: String(value.summary ?? '')
399
+ }
400
+ ]
401
+ },
402
+ async execute (args, _exec) {
403
+ const c = args.companion ?? {};
404
+ if (!c.evidence) return {
405
+ ok: false,
406
+ summary: 'evidence 必填(用户原话,溯源 P0)'
407
+ };
408
+ const ledger = ensureLedger(config.stateRoot);
409
+ const res = ledger.appendCompanion({
410
+ label: String(c.label ?? ''),
411
+ constraints: {
412
+ mobility: c.constraints?.mobility,
413
+ health: c.constraints?.health,
414
+ prefs: c.constraints?.prefs
415
+ },
416
+ evidence: c.evidence
417
+ });
418
+ if (!res.appended && res.reason) return {
419
+ ok: false,
420
+ summary: res.reason
421
+ };
422
+ return {
423
+ ok: true,
424
+ appended: res.appended,
425
+ companion_id: res.companionId,
426
+ total: res.total,
427
+ summary: res.appended ? `同行人已保存:${res.companionId}` : `无新内容(幂等跳过):${res.companionId}`
428
+ };
429
+ },
430
+ presentCall: (args)=>({
431
+ card: 'generic',
432
+ title: '保存同行人',
433
+ kind: 'edit',
434
+ rawInput: args.companion
435
+ })
436
+ }));
437
+ registerGuarded(defineTool({
438
+ name: 'gotry_trip_log',
439
+ description: 'Record a PAST trip into the travel timeline (memory-design M4 layer). Use when the user mentions a trip they already took ' + '(「去年国庆去了大理」) — dates resolve via the time anchor (词表外日期会拒绝,请用户给绝对日期), evidence MUST be the user\'s verbatim words. ' + 'Append-only and idempotent (same destination+start+source = same trip); overlapping same-destination trips are rejected for human adjudication. ' + 'Past trips power origin resolution and 「去过不再推」 ranking — never a hard filter.',
440
+ parameters: {
441
+ query: {
442
+ type: 'json',
443
+ required: true,
444
+ description: '{ destination: "大理", start: "2025-10-01 或绝对日期表达", end?: 同上, companions?: ["爸爸"], evidence: "<用户原话>" }'
445
+ }
446
+ },
447
+ output: {
448
+ schema: {
449
+ type: 'json'
450
+ },
451
+ render: (_args, value)=>[
452
+ {
453
+ type: 'text',
454
+ text: String(value.summary ?? '')
455
+ }
456
+ ]
457
+ },
458
+ async execute (args, _exec) {
459
+ const q = unwrapQuery(args, 'destination');
460
+ if (!q.destination) return {
461
+ ok: false,
462
+ summary: 'destination 必填'
463
+ };
464
+ if (!q.evidence) return {
465
+ ok: false,
466
+ summary: 'evidence 必填(用户原话,溯源 P0)'
467
+ };
468
+ const anchor = buildTimeAnchor(new Date());
469
+ const start = resolveTimelineDate(q.start ?? '', anchor) ?? (/^\d{4}-\d{2}-\d{2}$/.test(q.start ?? '') ? q.start : undefined);
470
+ if (!start) return {
471
+ ok: false,
472
+ summary: `start 无法解析为绝对日期(${q.start ?? '缺'})——请向用户要具体日期,不猜`
473
+ };
474
+ const end = q.end ? resolveTimelineDate(q.end, anchor) ?? (/^\d{4}-\d{2}-\d{2}$/.test(q.end) ? q.end : undefined) : undefined;
475
+ const ledger = ensureLedger(config.stateRoot);
476
+ const res = ledger.appendTripEvent({
477
+ destination: q.destination.trim(),
478
+ start,
479
+ end,
480
+ companions: q.companions,
481
+ source: 'user-verbatim',
482
+ evidence: q.evidence
483
+ });
484
+ if (!res.appended && res.reason) return {
485
+ ok: false,
486
+ summary: res.reason
487
+ };
488
+ return {
489
+ ok: true,
490
+ appended: res.appended,
491
+ trip_id: res.tripId,
492
+ total: res.total,
493
+ summary: res.appended ? `已入时间线:${q.destination} @ ${start}` : `已存在(幂等跳过):${res.tripId}`
494
+ };
495
+ },
496
+ presentCall: (args)=>({
497
+ card: 'generic',
498
+ title: '记录旅行',
499
+ kind: 'edit',
500
+ rawInput: args.query
501
+ })
502
+ }));
401
503
  registerGuarded(defineTool({
402
504
  name: 'gotry_hotel_search',
403
505
  description: 'Search hotels via hotelbyte-cli (real-time when hbcli credentials exist, falls back to the static pack with explicit evidence tagging). ' + 'Input: destination city name + optional dates/occupancy. Dates accept verbatim natural expressions (下周五 / 8.20 / 下周五+3) — ' + 'the code layer resolves them against the time anchor; unresolved expressions degrade to an undated search with an explicit ' + 'date_notes entry instead of guessing. Output: hotel list with evidence chain ([realtime-API:hbcli] + fetch timestamp, ' + 'or [static-pack:estimate]) per the L4 invariant.',
@@ -672,6 +774,133 @@ export function apply(ctx, config) {
672
774
  rawInput: args.query
673
775
  })
674
776
  }));
777
+ registerGuarded(defineTool({
778
+ name: 'gotry_flyai_search',
779
+ description: 'Live flight/train search via Fliggy official FlyAI channel (read-only, no key, booking only via jumpUrl by the human). ' + 'PRIMARY source for real schedules & prices (RFC user-session-data-rfc). Input: kind=flight|train, from/to city names (Chinese), date YYYY-MM-DD. ' + 'Evidence [实时API:flyai@ts]. Cross-validate expensive claims against gotry_session_search when it matters.',
780
+ parameters: {
781
+ query: {
782
+ type: 'json',
783
+ required: true,
784
+ description: '{ kind: "flight"|"train", from: "上海", to: "丽江", date: "2026-10-01" }'
785
+ }
786
+ },
787
+ output: {
788
+ schema: {
789
+ type: 'json'
790
+ },
791
+ render: (_a, v)=>[
792
+ {
793
+ type: 'text',
794
+ text: String(v.summary ?? JSON.stringify(v).slice(0, 600))
795
+ }
796
+ ]
797
+ },
798
+ async execute (args, _exec) {
799
+ const q = unwrapQuery(args, 'from');
800
+ const kind = q.kind === 'train' ? 'train' : 'flight';
801
+ if (!q.from || !q.to || !q.date) {
802
+ return {
803
+ ok: false,
804
+ summary: '需要 from/to(中文城市名)与 date(YYYY-MM-DD)'
805
+ };
806
+ }
807
+ const r = await flyaiSearch({
808
+ kind,
809
+ origin: q.from,
810
+ destination: q.to,
811
+ depDate: q.date
812
+ });
813
+ 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}`);
814
+ const summary = r.verdict === 'hit' ? `${q.from}→${q.to} ${q.date} ${kind === 'flight' ? '机票' : '火车票'}(飞猪官方只读)前 ${top.length} 条:\n${top.join('\n')}\n${r.evidence}` : `${q.from}→${q.to} ${q.date} 无结果或失败:${r.error ?? 'miss'} ${r.evidence}`;
815
+ return JSON.parse(JSON.stringify({
816
+ ...r,
817
+ kind,
818
+ summary
819
+ }));
820
+ },
821
+ presentCall: (args)=>({
822
+ card: 'generic',
823
+ title: `官方检索:${String(args.query?.kind ?? 'flight')}`,
824
+ kind: 'fetch',
825
+ rawInput: args.query
826
+ }),
827
+ presentResult: (args, value)=>{
828
+ const r = value;
829
+ return {
830
+ card: 'generic',
831
+ title: `飞猪检索:${r.ok && (r.options?.length ?? 0) > 0 ? `${r.options.length} 条` : '降级'}`,
832
+ content: [
833
+ {
834
+ type: 'text',
835
+ text: String(value.summary ?? '')
836
+ }
837
+ ]
838
+ };
839
+ }
840
+ }));
841
+ registerGuarded(defineTool({
842
+ name: 'gotry_session_search',
843
+ description: 'Cross-validation search on the user\'s OWN logged-in browser session (dedicated persistent profile, ReadGuard = physically read-only: ' + 'write requests are aborted at network layer; agent NEVER touches credentials/captcha; on captcha it stops and returns challenged). ' + 'Currently ctrip-flight: sniffs the site search API for structured options. Evidence [会话:ctrip-flight@ts]. ' + 'verdict needs-login = run scripts/session-login.ts once with the human logging in. Rate-limited (≥30s between same-site calls). ' + 'Use to verify/double-check prices from gotry_flyai_search on the same itinerary (split by 直达/中转).',
844
+ parameters: {
845
+ query: {
846
+ type: 'json',
847
+ required: true,
848
+ description: '{ from: "上海", to: "丽江", date: "2026-10-01" }'
849
+ }
850
+ },
851
+ output: {
852
+ schema: {
853
+ type: 'json'
854
+ },
855
+ render: (_a, v)=>[
856
+ {
857
+ type: 'text',
858
+ text: String(v.summary ?? JSON.stringify(v).slice(0, 600))
859
+ }
860
+ ]
861
+ },
862
+ async execute (args, _exec) {
863
+ const q = unwrapQuery(args, 'from');
864
+ if (!q.from || !q.to || !q.date) {
865
+ return {
866
+ ok: false,
867
+ summary: '需要 from/to(中文城市名,词表内)与 date(YYYY-MM-DD)'
868
+ };
869
+ }
870
+ const r = await sessionFlightSearch({
871
+ from: q.from,
872
+ to: q.to,
873
+ date: q.date,
874
+ auditPath: join(config.stateRoot ?? '.', 'gotry-state', 'session-incidents.jsonl')
875
+ });
876
+ 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}`);
877
+ const summary = r.verdict === 'hit' ? `${q.from}→${q.to} ${q.date} 会话检索(携程,用户本人登录态)前 ${top.length} 条:\n${top.join('\n')}\n${r.evidence}` : `会话检索未取回(${r.verdict}):${r.error ?? ''} ${r.evidence}`;
878
+ return JSON.parse(JSON.stringify({
879
+ ...r,
880
+ summary
881
+ }));
882
+ },
883
+ presentCall: (args)=>({
884
+ card: 'generic',
885
+ title: `会话检索:${String(args.query?.from ?? '')}`,
886
+ kind: 'fetch',
887
+ rawInput: args.query
888
+ }),
889
+ presentResult: (_args, value)=>{
890
+ const r = value;
891
+ const label = r.verdict === 'hit' ? `会话 ${r.options.length} 条` : r.verdict === 'needs-login' ? '需登录' : r.verdict ?? '降级';
892
+ return {
893
+ card: 'generic',
894
+ title: `会话检索:${label}`,
895
+ content: [
896
+ {
897
+ type: 'text',
898
+ text: String(value.summary ?? '')
899
+ }
900
+ ]
901
+ };
902
+ }
903
+ }));
675
904
  registerGuarded(defineTool({
676
905
  name: 'gotry_anything_search',
677
906
  description: 'Travel-domain search via hotel-byte CLI → hotel-be Anything (cities/hotels/destinations) — NOT general web search; for general internet facts use gotry_agent_reach. ' + 'Mixed destinations (cities / metropolitan areas / high-level regions) + hotels in one call. ' + 'Returns candidates with type, name, optional coordinates and hotel-id. ' + 'Three-valued semantics: hit = ≥1 candidate; miss = 0 candidates (try synonyms or contentType=city/hotel); ' + 'unavailable = hbcli failed (degraded, never blocks). ' + 'Use as the first stop when the user mentions a place/city/hotel name and you need to ground it in real catalog data ' + '(OpenFlights skeleton tells you connectivity; Anything tells you what EXISTS at a city/region).',
package/dist/src/loop.js CHANGED
@@ -264,36 +264,73 @@ export async function runTurn(state, userMsg, llm, history = [], solve = null) {
264
264
  };
265
265
  }
266
266
  import { join } from 'node:path';
267
- import { mkdir } from 'node:fs/promises';
268
- import { readFile, writeFile } from 'node:fs/promises';
269
- const ASYNC_DIR = 'gotry-state/async';
270
- async function asyncPath(name) {
271
- await mkdir(ASYNC_DIR, {
267
+ import { mkdir, rename, writeFile, readFile } from 'node:fs/promises';
268
+ import { ensureLedger, openLedgerIfExists } from './state-ledger.js';
269
+ async function asyncDir(stateRoot) {
270
+ const root = stateRoot === '.' ? process.cwd() : stateRoot;
271
+ const dir = join(root, 'gotry-state', 'async');
272
+ await mkdir(dir, {
272
273
  recursive: true
273
274
  });
274
- return join(ASYNC_DIR, name);
275
+ return dir;
275
276
  }
276
- export async function persistAsyncTicket(ticket, state) {
277
- const p = await asyncPath(`${ticket.id}.json`);
278
- await writeFile(p, JSON.stringify({
277
+ async function atomicWrite(path, text) {
278
+ const tmp = `${path}.tmp`;
279
+ await writeFile(tmp, text, 'utf-8');
280
+ await rename(tmp, path);
281
+ }
282
+ export async function persistAsyncTicket(ticket, state, stateRoot = '.') {
283
+ const ledger = ensureLedger(stateRoot);
284
+ ledger.createWorkflowRun({
285
+ id: ticket.id,
286
+ goal: ticket.objective,
287
+ ticket,
288
+ state
289
+ });
290
+ const dir = await asyncDir(stateRoot);
291
+ const p = join(dir, `${ticket.id}.json`);
292
+ await atomicWrite(p, JSON.stringify({
279
293
  ticket,
280
294
  state
281
- }, null, 2), 'utf-8');
295
+ }, null, 2));
282
296
  return p;
283
297
  }
284
- export async function loadAsyncTicket(ticketId) {
298
+ export async function loadAsyncTicket(ticketId, stateRoot = '.') {
299
+ const ledger = openLedgerIfExists(stateRoot);
300
+ const run = ledger?.getWorkflowRun(ticketId);
301
+ if (run) {
302
+ return {
303
+ ticket: JSON.parse(run.ticket_json),
304
+ state: JSON.parse(run.state_json)
305
+ };
306
+ }
285
307
  try {
286
- const p = await asyncPath(`${ticketId}.json`);
308
+ const p = join(await asyncDir(stateRoot), `${ticketId}.json`);
287
309
  return JSON.parse(await readFile(p, 'utf-8'));
288
310
  } catch {
289
311
  return null;
290
312
  }
291
313
  }
292
- export async function settleAsyncTicket(ticketId, reply) {
293
- const p = await asyncPath(`${ticketId}.deliverable.md`);
294
- await writeFile(p, reply, 'utf-8');
314
+ export async function settleAsyncTicket(ticketId, reply, stateRoot = '.') {
315
+ openLedgerIfExists(stateRoot)?.settleWorkflowRun(ticketId, reply);
316
+ const dir = await asyncDir(stateRoot);
317
+ const p = join(dir, `${ticketId}.deliverable.md`);
318
+ await atomicWrite(p, reply);
295
319
  return p;
296
320
  }
321
+ export function makeJournaledSolvePort(ledger, runId, solve, opts) {
322
+ return async (spec)=>{
323
+ const step = ledger.getWorkflowStep(runId, 'solve');
324
+ if (step?.status === 'done' && step.result) {
325
+ return JSON.parse(step.result);
326
+ }
327
+ ledger.markStepIntent(runId, 'solve');
328
+ opts?.onRealSolve?.();
329
+ const r = await solve(spec);
330
+ ledger.markStepDone(runId, 'solve', r);
331
+ return r;
332
+ };
333
+ }
297
334
  export function probePoi(msg) {
298
335
  const trimmed = msg.trim();
299
336
  if (!trimmed) return null;