@danceiny/gotry 0.0.1-rc.11 → 0.0.1-rc.13

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 (37) hide show
  1. package/README.md +111 -146
  2. package/cordis.gotry-patch.yml +14 -7
  3. package/dist/capabilities/flyai.js +135 -30
  4. package/dist/capabilities/hbcli.js +14 -2
  5. package/dist/capabilities/session/benchmark.js +252 -0
  6. package/dist/capabilities/session/transport.js +25 -10
  7. package/dist/capabilities/session-consent.js +82 -0
  8. package/dist/capabilities/session-login.js +119 -0
  9. package/dist/capabilities/session-search.js +7 -4
  10. package/dist/capabilities/weather.js +72 -17
  11. package/dist/scripts/agent-reach-wrapper-tests.js +4 -0
  12. package/dist/scripts/async-collect.js +33 -5
  13. package/dist/scripts/hbcli-tests.js +25 -4
  14. package/dist/scripts/ledger-tests.js +134 -4
  15. package/dist/scripts/memory-value-report.js +379 -0
  16. package/dist/scripts/product-metrics.js +569 -0
  17. package/dist/scripts/session-benchmark.js +261 -0
  18. package/dist/scripts/session-login.js +28 -0
  19. package/dist/scripts/session-tests.js +262 -23
  20. package/dist/scripts/smoke.js +112 -14
  21. package/dist/scripts/weather-tests.js +8 -1
  22. package/dist/src/index.js +124 -5
  23. package/dist/src/loop.js +65 -14
  24. package/dist/src/state-ledger.js +30 -4
  25. package/package.json +4 -2
  26. package/ts/capabilities/flyai.ts +177 -22
  27. package/ts/capabilities/hbcli.ts +16 -4
  28. package/ts/capabilities/session/benchmark.ts +273 -0
  29. package/ts/capabilities/session/transport.ts +41 -9
  30. package/ts/capabilities/session-consent.ts +127 -0
  31. package/ts/capabilities/session-login.ts +146 -0
  32. package/ts/capabilities/session-search.ts +13 -5
  33. package/ts/capabilities/weather.ts +84 -19
  34. package/ts/scripts/async-collect.ts +48 -7
  35. package/ts/src/index.ts +107 -11
  36. package/ts/src/loop.ts +85 -12
  37. package/ts/src/state-ledger.ts +48 -4
@@ -7,6 +7,7 @@ async function main() {
7
7
  const smokeRoot = mkdtempSync(join(tmpdir(), 'gotry-smoke-'));
8
8
  const registered = [];
9
9
  const variables = {};
10
+ const preExecutes = [];
10
11
  const ctx = {
11
12
  tools: {
12
13
  register: (t)=>registered.push(t)
@@ -15,13 +16,19 @@ async function main() {
15
16
  variable: (name, provider)=>{
16
17
  variables[name] = provider;
17
18
  }
19
+ },
20
+ on: (event, fn)=>{
21
+ if (event === 'tools/pre-execute') preExecutes.push(fn);
22
+ return ()=>{};
18
23
  }
19
24
  };
20
- apply(ctx, {
25
+ const cfg = {
21
26
  stateRoot: smokeRoot,
22
27
  timeoutMs: 30_000,
23
- hbcliBin: 'hbcli-not-on-path'
24
- });
28
+ hbcliBin: 'hbcli-not-on-path',
29
+ sessionAccess: 'ask'
30
+ };
31
+ apply(ctx, cfg);
25
32
  console.log(`registered tools: ${registered.map((t)=>t.name).join(', ')}`);
26
33
  const byName = (n)=>{
27
34
  const t = registered.find((t)=>t.name === n);
@@ -290,27 +297,118 @@ async function main() {
290
297
  }
291
298
  }, null);
292
299
  const faBlocked = fa.verdict === 'error' && /sentinel|block/i.test(fa.error ?? '');
300
+ const faErrTerminal = fa.ok === false && fa.verdict === 'error' && /^flyai-error$/.test(String(fa.via ?? '')) && /\[实时API:flyai@error@/.test(String(fa.evidence ?? ''));
293
301
  if (faBlocked) {
294
302
  console.log(' WARN - flyai Sentinel 限流中,降级合同通过(hit 断言跳过)');
303
+ } else if (faErrTerminal) {
304
+ console.log(' WARN - flyai 端点不可达(超时/降级),证据链合同通过(hit 断言跳过)');
295
305
  } else if (fa.ok !== true || fa.verdict !== 'hit' || (fa.options?.length ?? 0) < 1 || !/\[实时API:flyai@/.test(fa.evidence ?? '')) {
296
306
  throw new Error(`FAIL: flyai 工具应 live hit,实际:${JSON.stringify(fa).slice(0, 200)}`);
297
307
  }
298
308
  const prof = mkdtempSync(join(smokeRoot, 'sess-'));
299
- const ss = await byName('gotry_session_search').execute({
309
+ const faPast = await byName('gotry_flyai_search').execute({
300
310
  query: {
301
- from: '上海',
302
- to: '丽江',
303
- date: '2026-10-01'
311
+ kind: 'flight',
312
+ from: '深圳',
313
+ to: '普吉',
314
+ date: '2026-01-01'
315
+ }
316
+ }, null);
317
+ if (!(faPast.ok === false && /已是过去/.test(String(faPast.summary ?? '')))) {
318
+ throw new Error(`FAIL: flyai 过去日期应代码层预校验拒绝,实际:${JSON.stringify(faPast).slice(0, 200)}`);
319
+ }
320
+ const previousChromeUserDataDir = process.env.CHROME_USER_DATA_DIR;
321
+ process.env.CHROME_USER_DATA_DIR = prof;
322
+ let ss;
323
+ try {
324
+ ss = await byName('gotry_session_search').execute({
325
+ query: {
326
+ from: '上海',
327
+ to: '丽江',
328
+ date: '2026-10-01'
329
+ }
330
+ }, null);
331
+ } finally{
332
+ if (previousChromeUserDataDir === undefined) delete process.env.CHROME_USER_DATA_DIR;
333
+ else process.env.CHROME_USER_DATA_DIR = previousChromeUserDataDir;
334
+ rmSync(prof, {
335
+ recursive: true,
336
+ force: true
337
+ });
338
+ }
339
+ const ssErrTerminal = ss.ok === false && /^session-[a-z0-9-]+-error$/.test(String(ss.via ?? '')) && !!ss.evidence;
340
+ if (!(ss.verdict === 'needs-login' || ss.verdict === 'needs-attach' || ss.verdict === 'hit' || ss.verdict === 'cooldown' || ss.verdict === 'challenged') && !ssErrTerminal) {
341
+ throw new Error(`FAIL: session 工具终态应属 {needs-attach,needs-login,hit,cooldown,challenged} 或带证据的 error 终态,实际:${JSON.stringify(ss).slice(0, 200)}`);
342
+ }
343
+ console.log(`session-face tools: flyai ${faBlocked ? 'sentinel-限流降级' : `live hit(${fa.options?.length ?? 0} 条)`}; session 终态=${ss.verdict ?? ss.via}(登录态存在前提合同)`);
344
+ const fh = await byName('gotry_flyai_search').execute({
345
+ query: {
346
+ kind: 'hotel',
347
+ to: '大理',
348
+ checkIn: '2026-10-01',
349
+ checkOut: '2026-10-03'
304
350
  }
305
351
  }, null);
306
- rmSync(prof, {
307
- recursive: true,
308
- force: true
309
- });
310
- if (!(ss.verdict === 'needs-login' || ss.verdict === 'needs-attach' || ss.verdict === 'hit' || ss.verdict === 'cooldown' || ss.verdict === 'challenged')) {
311
- throw new Error(`FAIL: session 工具终态应属 {needs-login,hit,cooldown,challenged},实际:${JSON.stringify(ss).slice(0, 200)}`);
352
+ const fhBlocked = fh.verdict === 'error' && /sentinel|block/i.test(fh.error ?? '');
353
+ const fhErrTerminal = fh.ok === false && fh.verdict === 'error' && /^flyai-error$/.test(String(fh.via ?? '')) && /\[实时API:flyai@error@/.test(String(fh.evidence ?? ''));
354
+ if (fhBlocked || fhErrTerminal) {
355
+ console.log(' WARN - flyai hotel 限流/端点降级,证据链合同通过(hit 断言跳过)');
356
+ } else if (fh.ok !== true || fh.verdict !== 'hit' || (fh.hotels?.length ?? 0) < 1 || !/\[实时API:flyai@/.test(fh.evidence ?? '')) {
357
+ throw new Error(`FAIL: flyai hotel 应 live hit,实际:${JSON.stringify(fh).slice(0, 220)}`);
358
+ }
359
+ const fhDest = await byName('gotry_flyai_search').execute({
360
+ query: {
361
+ kind: 'hotel'
362
+ }
363
+ }, null);
364
+ if (fhDest.ok !== false) throw new Error('FAIL: hotel 缺目的地应参数闸拒绝');
365
+ const fhPast = await byName('gotry_flyai_search').execute({
366
+ query: {
367
+ kind: 'hotel',
368
+ to: '大理',
369
+ checkIn: '2026-01-01',
370
+ checkOut: '2026-01-03'
371
+ }
372
+ }, null);
373
+ if (!(fhPast.ok === false && /不是未来合法区间/.test(String(fhPast.summary ?? '')))) {
374
+ throw new Error(`FAIL: 酒店过去入住日应代码层预校验拒绝,实际:${JSON.stringify(fhPast).slice(0, 200)}`);
375
+ }
376
+ console.log(` hotel channel: ${fhBlocked ? 'sentinel-限流降级' : `${fh.hotels?.length ?? 0} 家`}; 参数闸/过去日闸生效`);
377
+ }
378
+ {
379
+ const gate = preExecutes.at(-1);
380
+ if (!gate) throw new Error('FAIL: tools/pre-execute 授权闸未注册');
381
+ const next = async ()=>({
382
+ kind: 'allow'
383
+ });
384
+ const ask = await gate({
385
+ name: 'gotry_session_search'
386
+ }, next);
387
+ if (ask.kind !== 'ask' || !/只读检索/.test(String(ask.reason ?? ''))) {
388
+ throw new Error(`FAIL: 会话工具无审批通道时应交 ask(运行时原生结算),实际:${JSON.stringify(ask)}`);
389
+ }
390
+ const pass = await gate({
391
+ name: 'gotry_anything_search'
392
+ }, next);
393
+ if (pass.kind !== 'allow') throw new Error(`FAIL: 非会话工具应原样放行,实际:${JSON.stringify(pass)}`);
394
+ cfg.sessionAccess = 'off';
395
+ const deny = await gate({
396
+ name: 'gotry_session_search'
397
+ }, next);
398
+ if (deny.kind !== 'deny' || !/sessionAccess=off/.test(String(deny.reason ?? ''))) {
399
+ throw new Error(`FAIL: sessionAccess=off 应 fail-closed deny,实际:${JSON.stringify(deny)}`);
400
+ }
401
+ cfg.sessionAccess = 'ask';
402
+ console.log('consent gate: session tool → approval-card ask(每会话一次/拒绝即会话内吊销); off → fail-closed deny; other tools pass through');
403
+ }
404
+ {
405
+ const lo = byName('gotry_session_login');
406
+ if (typeof lo.presentResult !== 'function') throw new Error('FAIL: gotry_session_login 缺 presentResult');
407
+ const desc = String(lo.description ?? '');
408
+ if (!/NEVER collects, stores, or transmits credentials/.test(desc)) {
409
+ throw new Error(`FAIL: 登录工具描述缺「不经手凭证」语义红线,实际 ${desc.slice(0, 120)}`);
312
410
  }
313
- console.log(`session-face tools: flyai ${faBlocked ? 'sentinel-限流降级' : `live hit(${fa.options?.length ?? 0} )`}; session 终态=${ss.verdict}(登录态存在前提合同)`);
411
+ console.log('login tool: registered; 登录=外部网站+用户自己的浏览器,gotry 只读票据名(0 值过手)语义钉死');
314
412
  }
315
413
  rmSync(smokeRoot, {
316
414
  recursive: true,
@@ -9,6 +9,13 @@ const dali = geo.results.find((r)=>(r.admin1 ?? '').includes('云南')) ?? geo.r
9
9
  assert.ok(Math.abs(dali.latitude - 25.6) < 0.5, `纬度应≈25.6,实际 ${dali.latitude}(${dali.name},${dali.admin1})`);
10
10
  assert.match(geo.evidence, /open-meteo-geo@2/, '证据链带时间戳');
11
11
  console.log(`1. geocode 大理市 → ${dali.latitude},${dali.longitude} (${dali.name},${dali.admin1}) OK`);
12
+ const phuket = await geocodePlace('普吉岛');
13
+ assert.equal(phuket.ok, true, `普吉岛 geocode ok: ${phuket.error ?? ''}`);
14
+ assert.equal(phuket.via, 'nominatim', 'open-meteo 0 结果应走 nominatim 兜底层');
15
+ assert.ok(Math.abs(phuket.results[0].latitude - 8.0) < 0.5, `普吉岛纬度应≈8,实际 ${phuket.results[0].latitude}(${phuket.results[0].name})`);
16
+ assert.match(phuket.results[0].country ?? '', /泰国/, '国家标签应为泰国');
17
+ assert.match(phuket.evidence, /\[实时API:nominatim@2/, '兜底层证据链带时间戳');
18
+ console.log(`1b. geocode 普吉岛(兜底层)→ ${phuket.results[0].name}(${phuket.results[0].admin1},${phuket.results[0].country}) OK`);
12
19
  const fc = await getForecast({
13
20
  latitude: dali.latitude,
14
21
  longitude: dali.longitude
@@ -42,7 +49,7 @@ assert.equal(wmoLabel(0), '晴');
42
49
  assert.equal(wmoLabel(95), '雷暴');
43
50
  assert.match(wmoLabel(999), /天气码999/, '未知码回退');
44
51
  console.log('5. WMO 码映射 OK');
45
- console.log('\nWEATHER TESTS: 5/5 OK(Open-Meteo 真实 API,免费无 key)');
52
+ console.log('\nWEATHER TESTS: 6/6 OK(Open-Meteo 真实 API,免费无 key;普吉岛走 Nominatim 兜底层)');
46
53
 
47
54
 
48
55
  //# sourceURL=/Users/bytedance/work/gotry/ts/scripts/weather-tests.ts
package/dist/src/index.js CHANGED
@@ -21,6 +21,8 @@ import { readUrl, reach, reachStatus } from '../capabilities/agent-reach.js';
21
21
  import { videoSubtitle, githubSearch } from '../capabilities/agent-reach-deep.js';
22
22
  import { flyaiSearch } from '../capabilities/flyai.js';
23
23
  import { sessionFlightSearch } from '../capabilities/session-search.js';
24
+ import { sessionLogin } from '../capabilities/session-login.js';
25
+ import { createConsentGate, approvalFromContext } from '../capabilities/session-consent.js';
24
26
  export const name = 'gotry-tools';
25
27
  export const inject = [
26
28
  'tools',
@@ -29,7 +31,8 @@ export const inject = [
29
31
  export const Config = z.object({
30
32
  stateRoot: z.string().default('.'),
31
33
  timeoutMs: z.number().default(30_000),
32
- hbcliBin: z.string().default('hbcli')
34
+ hbcliBin: z.string().default('hbcli'),
35
+ sessionAccess: z.string().default('ask')
33
36
  });
34
37
  const unwrapQuery = interpretArgs;
35
38
  function renderMotivationBrief(stateRoot) {
@@ -95,6 +98,13 @@ export function apply(ctx, config) {
95
98
  uncaughtException: 'gotry-tools',
96
99
  unhandledRejection: 'gotry-tools'
97
100
  });
101
+ const ctxOn = ctx.on;
102
+ if (typeof ctxOn === 'function') {
103
+ ctx.on('tools/pre-execute', createConsentGate({
104
+ access: ()=>config.sessionAccess ?? 'ask',
105
+ approval: approvalFromContext(ctx)
106
+ }));
107
+ }
98
108
  const registerGuarded = (tool)=>{
99
109
  const t = {
100
110
  ...tool
@@ -776,12 +786,12 @@ export function apply(ctx, config) {
776
786
  }));
777
787
  registerGuarded(defineTool({
778
788
  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.',
789
+ description: 'Live travel search through the Fliggy official FlyAI channel (read-only, no key; booking/comparison happens by the HUMAN on the jumpUrl page). ' + 'kind="flight"|"train": { kind, from, to, date } (中文城市名, date YYYY-MM-DD) — real schedules & prices, split 直达/中转 in results. ' + 'kind="hotel": { kind:"hotel", to:"大理"(目的地中文), checkIn?, checkOut? (YYYY-MM-DD,成对可选——未定档期可不填先摸底), keyWords? }. ' + 'Hotel prices may be masked upstream (priceRaw like "¥7xx"): always present the mask as a range, and let the human open jumpUrl for the real price. ' + 'Evidence [实时API:flyai@ts]. Errors (rate-limit Sentinel / invalid dates) degrade as structured errors with the upstream message — surface them, never guess.',
780
790
  parameters: {
781
791
  query: {
782
792
  type: 'json',
783
793
  required: true,
784
- description: '{ kind: "flight"|"train", from: "上海", to: "丽江", date: "2026-10-01" }'
794
+ description: 'kind=flight|train: { kind, from: "上海", to: "丽江", date: "2026-10-01" }; kind="hotel": { kind:"hotel", to:"大理", checkIn?: "YYYY-MM-DD", checkOut?: "YYYY-MM-DD", keyWords?: "洱海" }'
785
795
  }
786
796
  },
787
797
  output: {
@@ -797,6 +807,50 @@ export function apply(ctx, config) {
797
807
  },
798
808
  async execute (args, _exec) {
799
809
  const q = unwrapQuery(args, 'from');
810
+ if (q.kind === 'hotel') {
811
+ const dest = (q.to ?? '').trim();
812
+ if (!dest) return {
813
+ ok: false,
814
+ summary: 'kind=hotel 需要 to(目的地中文,如 大理)'
815
+ };
816
+ if ((q.checkIn ? 1 : 0) !== (q.checkOut ? 1 : 0) || q.checkIn && !/^\d{4}-\d{2}-\d{2}$/.test(q.checkIn)) {
817
+ return {
818
+ ok: false,
819
+ summary: '酒店 checkIn/checkOut 须成对且为 YYYY-MM-DD(未定档期可不填,先摸底)'
820
+ };
821
+ }
822
+ if (q.checkIn) {
823
+ const now = new Date();
824
+ const todayYmd = `${now.getFullYear()}-${String(now.getMonth() + 1).padStart(2, '0')}-${String(now.getDate()).padStart(2, '0')}`;
825
+ if (q.checkIn < todayYmd || (q.checkOut ?? '') < q.checkIn) {
826
+ return JSON.parse(JSON.stringify({
827
+ ok: false,
828
+ verdict: 'error',
829
+ kind: 'hotel',
830
+ summary: `未发起查询:入住 ${q.checkIn}/退房 ${q.checkOut ?? ''} 不是未来合法区间(今天 ${todayYmd})。向用户确认日期后再查。`
831
+ }));
832
+ }
833
+ if ((q.checkOut ?? '').length !== 10) {
834
+ return {
835
+ ok: false,
836
+ summary: 'checkOut 需 YYYY-MM-DD(与 checkIn 成对)'
837
+ };
838
+ }
839
+ }
840
+ const r = await flyaiSearch({
841
+ kind: 'hotel',
842
+ destName: dest,
843
+ checkInDate: q.checkIn,
844
+ checkOutDate: q.checkOut,
845
+ keyWords: q.keyWords
846
+ });
847
+ const top = (r.hotels ?? []).slice(0, 8).map((o)=>`${o.name}${o.star ? `(${o.star})` : ''} ${o.priceRaw ?? '价待询'}${o.poi ? ` · ${o.poi}` : ''}`);
848
+ const summary = r.verdict === 'hit' ? `${dest} 酒店(飞猪官方只读)前 ${top.length} 家(价格多为打码,真实价以 jumpUrl 为准):\n${top.join('\n')}\n${r.evidence}` : `${dest} 酒店无结果或失败:${r.error ?? 'miss'} ${r.evidence}`;
849
+ return JSON.parse(JSON.stringify({
850
+ ...r,
851
+ summary
852
+ }));
853
+ }
800
854
  const kind = q.kind === 'train' ? 'train' : 'flight';
801
855
  if (!q.from || !q.to || !q.date) {
802
856
  return {
@@ -804,6 +858,16 @@ export function apply(ctx, config) {
804
858
  summary: '需要 from/to(中文城市名)与 date(YYYY-MM-DD)'
805
859
  };
806
860
  }
861
+ const now = new Date();
862
+ const todayYmd = `${now.getFullYear()}-${String(now.getMonth() + 1).padStart(2, '0')}-${String(now.getDate()).padStart(2, '0')}`;
863
+ if (q.date < todayYmd) {
864
+ return JSON.parse(JSON.stringify({
865
+ ok: false,
866
+ verdict: 'error',
867
+ kind,
868
+ summary: `未发起查询:日期 ${q.date} 已是过去(今天 ${todayYmd}),过去不存在在售机/火车票。` + `多为用户时间表达未带年份所致——向用户确认年份(或按未来最近的同月日修正)后再查。`
869
+ }));
870
+ }
807
871
  const r = await flyaiSearch({
808
872
  kind,
809
873
  origin: q.from,
@@ -825,10 +889,65 @@ export function apply(ctx, config) {
825
889
  rawInput: args.query
826
890
  }),
827
891
  presentResult: (args, value)=>{
892
+ const isHotel = String(args.query?.kind) === 'hotel';
893
+ const r = value;
894
+ const n = Math.max((r.options ?? []).length, (r.hotels ?? []).length);
895
+ return {
896
+ card: 'generic',
897
+ title: `${isHotel ? '飞猪酒店' : '飞猪检索'}:${r.ok && n > 0 ? `${n} 条` : '降级'}`,
898
+ content: [
899
+ {
900
+ type: 'text',
901
+ text: String(value.summary ?? '')
902
+ }
903
+ ]
904
+ };
905
+ }
906
+ }));
907
+ registerGuarded(defineTool({
908
+ name: 'gotry_session_login',
909
+ description: 'Productized login bootstrap for the account session channel (call this when gotry_session_search returns needs-login — the user never needs a terminal). ' + 'AUTO-DETECTION FIRST: it reads ticket-cookie NAMES before anything else — if the user already logged in (on the external site) it confirms instantly WITHOUT opening any page. ' + 'OPENS the site login entry in the USER\'S OWN Chrome and waits for the user to finish logging in on the external site. ' + 'GoTry NEVER collects, stores, or transmits credentials: no passwords, no SMS codes, no cookie values — it only checks the boolean fact "already logged in" (reads cookie NAMES only, zero values). ' + 'verdict logged-in (tickets detected) | pending (login tab opened, user not done yet — offer to re-check later) | needs-attach (one-time Chrome remote-debugging switch instructions). ' + 'Evidence [会话:<site>-login@ts].',
910
+ parameters: {
911
+ query: {
912
+ type: 'json',
913
+ required: true,
914
+ description: '可空对象 {}: { waitSeconds?: number }(等待用户完成登录的上限秒数,默认 90,至多 300)'
915
+ }
916
+ },
917
+ output: {
918
+ schema: {
919
+ type: 'json'
920
+ },
921
+ render: (_a, v)=>[
922
+ {
923
+ type: 'text',
924
+ text: String(v.evidence ?? JSON.stringify(v).slice(0, 600))
925
+ }
926
+ ]
927
+ },
928
+ async execute (args, _exec) {
929
+ const q = unwrapQuery(args, 'waitSeconds');
930
+ const r = await sessionLogin({
931
+ waitMs: typeof q?.waitSeconds === 'number' ? q.waitSeconds * 1000 : undefined
932
+ });
933
+ const summary = r.verdict === 'logged-in' ? `${r.site} 登录完成确认(票据 cookie 名已检出,只读名字)。说明:登录是在携程官网、用你自己的浏览器完成的——gotry 全程未接触任何密码/验证码/cookie 值。现在可以继续会话检索了。${r.evidence}` : r.verdict === 'pending' ? `登录入口已在你的 Chrome 打开;请在弹出的标签页里正常登录携程(登录由你在官网完成,不属于 gotry)。完成后说一声"继续",我再确认。gotry 只检查"是否已登录",永不收集你的账号信息。${r.evidence}` : r.verdict === 'needs-attach' ? `需要一次性开启你 Chrome 的远程调试开关:在你的 Chrome 地址栏打开 chrome://inspect/#remote-debugging 并打开开关,然后说一声"重试"。${r.evidence}` : `登录引导未完成:${r.error ?? '未知原因'} ${r.evidence}`;
934
+ return JSON.parse(JSON.stringify({
935
+ ...r,
936
+ summary
937
+ }));
938
+ },
939
+ presentCall: (_args)=>({
940
+ card: 'generic',
941
+ title: '登录携程(用你自己的浏览器,不在 gotry 输入)',
942
+ kind: 'fetch',
943
+ rawInput: {}
944
+ }),
945
+ presentResult: (_args, value)=>{
828
946
  const r = value;
947
+ const label = r.verdict === 'logged-in' ? `已登录(${(r.tickets ?? []).length} 票据)` : r.verdict === 'pending' ? '等待你在携程页面完成登录' : r.verdict ?? '降级';
829
948
  return {
830
949
  card: 'generic',
831
- title: `飞猪检索:${r.ok && (r.options?.length ?? 0) > 0 ? `${r.options.length} 条` : '降级'}`,
950
+ title: `账号登录:${label}`,
832
951
  content: [
833
952
  {
834
953
  type: 'text',
@@ -840,7 +959,7 @@ export function apply(ctx, config) {
840
959
  }));
841
960
  registerGuarded(defineTool({
842
961
  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 直达/中转).',
962
+ description: 'Search on the USER\'S OWN logged-in browser session (Ctrip flights today; the account channel, not an anonymous instance). ' + 'Consent gate: the FIRST call in a session asks the user via the runtime approval card; once granted it holds for the session, a refusal revokes it for the session (no repeat prompting). ' + 'ReadGuard = physically read-only (write requests 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 = call gotry_session_login (product tool: opens the Ctrip login entry in the user\'s own Chrome and waits for them to log in on the external site — no terminal, no credentials through GoTry); needs-attach = one-time Chrome remote-debugging switch. ' + 'Rate-limited (≥30s between same-site calls; a challenged/timeout verdict means STOP never retry, fall back to other tools).',
844
963
  parameters: {
845
964
  query: {
846
965
  type: 'json',
package/dist/src/loop.js CHANGED
@@ -71,6 +71,35 @@ export function validateSpec(spec) {
71
71
  }
72
72
  return null;
73
73
  }
74
+ export const ASYNC_TERMINAL_SCHEMA = 'gotry_async_terminal.v1';
75
+ function asyncTerminalOutcome(ticketId, checks) {
76
+ const failedChecks = Object.entries(checks).filter(([, passed])=>!passed).map(([name])=>name);
77
+ return {
78
+ schema: ASYNC_TERMINAL_SCHEMA,
79
+ ticket_id: ticketId,
80
+ status: failedChecks.length === 0 ? 'succeeded' : 'failed',
81
+ passed: Object.values(checks).filter(Boolean).length,
82
+ total: 4,
83
+ checks,
84
+ failed_checks: failedChecks
85
+ };
86
+ }
87
+ function terminalOutcomeFromState(ticketId, state) {
88
+ if (!state.spec || !state.solve) {
89
+ return asyncTerminalOutcome(ticketId, {
90
+ '1_承诺时间后必有明确产物': false,
91
+ '2_产物通过自检清单': false,
92
+ '3_待决问题全部是简单选择题': false,
93
+ '4_做不到的诚实说': false
94
+ });
95
+ }
96
+ return asyncTerminalOutcome(ticketId, {
97
+ '1_承诺时间后必有明确产物': Boolean(state.solve.legs?.length || state.solve.verdicts?.length),
98
+ '2_产物通过自检清单': state.solve.feasible ? state.solve.legs?.every((l)=>l['energy_pct'] !== undefined) ?? false : Boolean(state.solve.unsat_core?.length),
99
+ '3_待决问题全部是简单选择题': state.gates.every((g)=>g.id === 'budget' || g.options.length >= 2),
100
+ '4_做不到的诚实说': state.solve.feasible || Boolean(state.solve.suggestions?.length)
101
+ });
102
+ }
74
103
  export function isComplex(state) {
75
104
  const p = state.profile;
76
105
  return Boolean(p.workWindow && p.bookedResources) && (state.gates.length > 0 || Boolean(state.spec));
@@ -95,22 +124,22 @@ export async function requestDeepPlanning(state) {
95
124
  }
96
125
  export async function collectDeepPlanning(state, ticket, solve) {
97
126
  const spec = state.spec;
98
- if (!spec) return {
99
- reply: '(内部状态缺失 spec——深度规划未就绪,这是不应发生的路径)',
100
- state
101
- };
127
+ if (!spec) {
128
+ const outcome = terminalOutcomeFromState(ticket.id, state);
129
+ return {
130
+ reply: '(内部状态缺失 spec——深度规划未就绪,这是不应发生的路径)',
131
+ state,
132
+ outcome
133
+ };
134
+ }
102
135
  state.solve = await solve(spec);
103
136
  state.gates = state.gates.filter((g)=>!g.id.startsWith('async-'));
104
- const checks = {
105
- '1_承诺时间后必有明确产物': Boolean(state.solve.legs?.length || state.solve.verdicts?.length),
106
- '2_产物通过自检清单': state.solve.feasible ? state.solve.legs?.every((l)=>l['energy_pct'] !== undefined) ?? false : Boolean(state.solve.unsat_core?.length),
107
- '3_待决问题全部是简单选择题': state.gates.every((g)=>g.id === 'budget' || g.options.length >= 2),
108
- '4_做不到的诚实说': state.solve.feasible || Boolean(state.solve.suggestions?.length)
109
- };
110
- const head = `# 回访交付:${ticket.objective}\n(工单 ${ticket.id},不失望四条:${Object.values(checks).every(Boolean) ? '4/4 ✅' : '有未达项 ❌'})`;
137
+ const outcome = terminalOutcomeFromState(ticket.id, state);
138
+ const head = `# 回访交付:${ticket.objective}\n(工单 ${ticket.id},不失望四条:${outcome.status === 'succeeded' ? '4/4 ✅' : '有未达项 ❌'})`;
111
139
  return {
112
140
  reply: `${head}\n\n${renderSolve(state)}`,
113
- state
141
+ state,
142
+ outcome
114
143
  };
115
144
  }
116
145
  export function renderSolve(state) {
@@ -311,13 +340,35 @@ export async function loadAsyncTicket(ticketId, stateRoot = '.') {
311
340
  return null;
312
341
  }
313
342
  }
314
- export async function settleAsyncTicket(ticketId, reply, stateRoot = '.') {
315
- openLedgerIfExists(stateRoot)?.settleWorkflowRun(ticketId, reply);
343
+ export async function settleAsyncTicket(ticketId, reply, stateRoot = '.', outcome) {
344
+ const ledger = openLedgerIfExists(stateRoot);
345
+ const terminalOutcome = outcome ?? recoverAsyncTerminalOutcome(ledger, ticketId);
346
+ if (ledger && !terminalOutcome) {
347
+ throw new Error(`workflow ${ticketId} 缺少可恢复的 ${ASYNC_TERMINAL_SCHEMA},拒绝误结算`);
348
+ }
349
+ if (terminalOutcome?.status === 'failed') ledger?.failWorkflowRun(ticketId, reply, terminalOutcome);
350
+ else ledger?.settleWorkflowRun(ticketId, reply, terminalOutcome);
316
351
  const dir = await asyncDir(stateRoot);
317
352
  const p = join(dir, `${ticketId}.deliverable.md`);
318
353
  await atomicWrite(p, reply);
319
354
  return p;
320
355
  }
356
+ function recoverAsyncTerminalOutcome(ledger, ticketId) {
357
+ if (!ledger) return undefined;
358
+ const run = ledger.getWorkflowRun(ticketId);
359
+ if (!run) return undefined;
360
+ try {
361
+ const state = JSON.parse(run.state_json);
362
+ if (!state.spec) return terminalOutcomeFromState(ticketId, state);
363
+ const solveStep = ledger.getWorkflowStep(ticketId, 'solve');
364
+ if (solveStep?.status !== 'done' || !solveStep.result) return undefined;
365
+ state.solve = JSON.parse(solveStep.result);
366
+ state.gates = state.gates.filter((g)=>!g.id.startsWith('async-'));
367
+ return terminalOutcomeFromState(ticketId, state);
368
+ } catch {
369
+ return undefined;
370
+ }
371
+ }
321
372
  export function makeJournaledSolvePort(ledger, runId, solve, opts) {
322
373
  return async (spec)=>{
323
374
  const step = ledger.getWorkflowStep(runId, 'solve');
@@ -543,21 +543,47 @@ export class StateLedger {
543
543
  markStepDone(runId, name, result) {
544
544
  this.db.prepare(`UPDATE workflow_steps SET status = 'done', result = ?, done_ts = ? WHERE run_id = ? AND name = ? AND tenant_id = ?`).run(JSON.stringify(result), new Date().toISOString(), runId, name, this.tenant);
545
545
  }
546
- settleWorkflowRun(id, deliverable, actor = 'system:async-collect') {
546
+ finishWorkflowRun(id, status, eventKind, deliverable, terminalOutcome, actor = 'system:async-collect') {
547
547
  const run = this.db.transaction(()=>{
548
- this.db.prepare(`UPDATE workflow_runs SET status = 'settled', deliverable = ?, updated = ? WHERE id = ? AND tenant_id = ?`).run(deliverable, new Date().toISOString(), id, this.tenant);
548
+ const update = this.db.prepare(`UPDATE workflow_runs SET status = ?, deliverable = ?, updated = ?
549
+ WHERE id = ? AND tenant_id = ? AND status = 'pending'`).run(status, deliverable, new Date().toISOString(), id, this.tenant);
550
+ if (update.changes === 0) {
551
+ const existing = this.getWorkflowRun(id);
552
+ if (existing?.status === status) return;
553
+ throw new Error(`workflow ${id} cannot transition ${existing?.status ?? 'missing'} -> ${status}`);
554
+ }
549
555
  this.insertEvent({
550
556
  actor,
551
- kind: 'async.settled',
557
+ kind: eventKind,
552
558
  subjectId: id,
553
559
  payload: {
554
- bytes: deliverable.length
560
+ bytes: deliverable.length,
561
+ terminal_outcome: terminalOutcome
555
562
  },
563
+ idemKey: `async-terminal:${id}`,
556
564
  runId: id
557
565
  });
558
566
  });
559
567
  run();
560
568
  }
569
+ settleWorkflowRun(id, deliverable, terminalOutcome, actor = 'system:async-collect') {
570
+ this.finishWorkflowRun(id, 'settled', 'async.settled', deliverable, terminalOutcome, actor);
571
+ }
572
+ failWorkflowRun(id, deliverable, terminalOutcome, actor = 'system:async-collect') {
573
+ this.finishWorkflowRun(id, 'failed', 'async.failed', deliverable, terminalOutcome, actor);
574
+ }
575
+ getWorkflowTerminalOutcome(id) {
576
+ const row = this.db.prepare(`SELECT payload FROM events
577
+ WHERE tenant_id = ? AND run_id = ? AND kind IN ('async.settled', 'async.failed')
578
+ ORDER BY seq DESC LIMIT 1`).get(this.tenant, id);
579
+ if (!row) return null;
580
+ try {
581
+ const payload = JSON.parse(row.payload);
582
+ return payload.terminal_outcome ?? null;
583
+ } catch {
584
+ return null;
585
+ }
586
+ }
561
587
  requestPendingWrite(input, actor = 'system:writegate') {
562
588
  const ts = new Date().toISOString();
563
589
  const run = this.db.transaction(()=>{
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@danceiny/gotry",
3
- "version": "0.0.1-rc.11",
3
+ "version": "0.0.1-rc.13",
4
4
  "description": "GoTry — 从出发到下一次出发的 AI 旅行 Agent(dsh 插件)。npm 包入口 + vendored dsh runtime + 5 行 README 安装路径。",
5
5
  "type": "module",
6
6
  "main": "ts/src/index.ts",
@@ -47,7 +47,9 @@
47
47
  "ts/package.json",
48
48
  "cordis.gotry-patch.yml",
49
49
  "README.md",
50
- "LICENSE"
50
+ "LICENSE",
51
+ "ts/capabilities/session-consent.ts",
52
+ "ts/capabilities/session-login.ts"
51
53
  ],
52
54
  "engines": {
53
55
  "node": ">=22.0.0"