@danceiny/gotry 0.0.1-rc.7 → 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 (75) hide show
  1. package/README.md +29 -74
  2. package/bin/gotry-inner.js +50 -1
  3. package/bin/gotry-stdio-ask.js +59 -0
  4. package/cordis.gotry-patch.yml +63 -5
  5. package/data/flights_2026.json +7 -4
  6. package/data/hotels_2026.json +102 -15
  7. package/data/time-slot-eval.json +384 -0
  8. package/data/yunnan-pack.json +2 -1
  9. package/dist/capabilities/agent-reach-bridge.py +90 -0
  10. package/dist/capabilities/anything.js +2 -2
  11. package/dist/capabilities/flyai.js +131 -0
  12. package/dist/capabilities/hbcli.js +11 -6
  13. package/dist/capabilities/incident-log.js +2 -1
  14. package/dist/capabilities/session/action-cache.js +120 -0
  15. package/dist/capabilities/session/adapters/ctrip-flight.js +83 -0
  16. package/dist/capabilities/session/adapters/meituan-local.js +73 -0
  17. package/dist/capabilities/session/extract.js +40 -0
  18. package/dist/capabilities/session/read-guard.js +56 -0
  19. package/dist/capabilities/session/transport.js +95 -0
  20. package/dist/capabilities/session-search.js +95 -0
  21. package/dist/scripts/action-cache-tests.js +107 -0
  22. package/dist/scripts/agent-reach-tests.js +1 -1
  23. package/dist/scripts/agent-reach-wrapper-tests.js +1 -1
  24. package/dist/scripts/async-collect.js +26 -7
  25. package/dist/scripts/companion-tests.js +112 -0
  26. package/dist/scripts/hbcli-tests.js +18 -2
  27. package/dist/scripts/ledger-tests.js +344 -0
  28. package/dist/scripts/ledger-workflow-crash.js +42 -0
  29. package/dist/scripts/memory-capture-tests.js +77 -0
  30. package/dist/scripts/memory-decay-tests.js +83 -0
  31. package/dist/scripts/memory-metrics.js +24 -0
  32. package/dist/scripts/nudge-digest.js +83 -0
  33. package/dist/scripts/probe-poi-tests.js +9 -0
  34. package/dist/scripts/replay.js +73 -1
  35. package/dist/scripts/session-attach-diagnose.js +31 -0
  36. package/dist/scripts/session-attach-poc.js +65 -0
  37. package/dist/scripts/session-attach-wait.js +41 -0
  38. package/dist/scripts/session-extract-tests.js +81 -0
  39. package/dist/scripts/session-login.js +48 -0
  40. package/dist/scripts/session-tests.js +162 -0
  41. package/dist/scripts/skeleton-check.js +3 -1
  42. package/dist/scripts/skills-contract-tests.js +85 -0
  43. package/dist/scripts/smoke.js +242 -4
  44. package/dist/scripts/state-cli-tests.js +136 -0
  45. package/dist/scripts/state-cli.js +234 -0
  46. package/dist/scripts/time-eval-tests.js +318 -0
  47. package/dist/scripts/travel-timeline-tests.js +123 -0
  48. package/dist/scripts/unified-tests.js +1 -1
  49. package/dist/src/companions.js +112 -0
  50. package/dist/src/dsh-llm.js +27 -7
  51. package/dist/src/index.js +600 -121
  52. package/dist/src/loop.js +89 -25
  53. package/dist/src/memory-capture.js +40 -0
  54. package/dist/src/memory-decay.js +31 -0
  55. package/dist/src/memory-utility.js +55 -0
  56. package/dist/src/mock-llm.js +6 -1
  57. package/dist/src/slot-spec.js +165 -0
  58. package/dist/src/state-ledger.js +848 -0
  59. package/dist/src/time-anchor.js +100 -0
  60. package/dist/src/tool-packet.js +12 -0
  61. package/dist/src/travel-slots.js +144 -0
  62. package/dist/src/travel-timeline.js +78 -0
  63. package/dist/src/unified.js +3 -1
  64. package/dist/src/wish-pool.js +27 -0
  65. package/package.json +6 -2
  66. package/ts/capabilities/hbcli.ts +6 -6
  67. package/ts/capabilities/incident-log.ts +6 -3
  68. package/ts/package.json +3 -0
  69. package/ts/scripts/skeleton-check.ts +5 -1
  70. package/ts/src/dsh-llm.ts +27 -7
  71. package/ts/src/index.ts +439 -91
  72. package/ts/src/loop.ts +109 -34
  73. package/ts/src/mock-llm.ts +13 -1
  74. package/ts/src/unified.ts +6 -1
  75. package/ts/cordis.gotry-patch.yml +0 -36
@@ -0,0 +1,120 @@
1
+ import { createHash } from 'node:crypto';
2
+ import { mkdirSync, readFileSync, writeFileSync, existsSync, rmSync } from 'node:fs';
3
+ import { dirname, join } from 'node:path';
4
+ export function templateKey(site, action, params) {
5
+ const val = (k)=>k === 'date' ? '%date%' : (params[k] ?? '').trim();
6
+ const head = [
7
+ 'from',
8
+ 'to',
9
+ 'date'
10
+ ].filter((k)=>k in params).map(val);
11
+ const tail = Object.keys(params).filter((k)=>![
12
+ 'from',
13
+ 'to',
14
+ 'date'
15
+ ].includes(k)).sort().map(val);
16
+ return `${site}:${action}:${[
17
+ ...head,
18
+ ...tail
19
+ ].join('|')}`;
20
+ }
21
+ export function fingerprint(shapeText) {
22
+ return createHash('sha256').update(shapeText).digest('hex').slice(0, 16);
23
+ }
24
+ export class ActionCache {
25
+ filePath;
26
+ entries = new Map();
27
+ loaded = false;
28
+ ttlMs;
29
+ maxPerSite;
30
+ now;
31
+ constructor(filePath, opts = {}){
32
+ this.filePath = filePath;
33
+ this.ttlMs = opts.ttlMs ?? 48 * 3600_000;
34
+ this.maxPerSite = opts.maxEntriesPerSite ?? 50;
35
+ this.now = opts.now ?? (()=>new Date());
36
+ }
37
+ load() {
38
+ if (this.loaded) return;
39
+ this.loaded = true;
40
+ try {
41
+ if (existsSync(this.filePath)) {
42
+ const raw = JSON.parse(readFileSync(this.filePath, 'utf8'));
43
+ for (const e of raw){
44
+ if (e?.key && e.domFingerprint && e.locator) this.entries.set(e.key, e);
45
+ }
46
+ }
47
+ } catch {}
48
+ }
49
+ persist() {
50
+ try {
51
+ mkdirSync(dirname(this.filePath), {
52
+ recursive: true
53
+ });
54
+ const tmp = this.filePath + '.tmp';
55
+ writeFileSync(tmp, JSON.stringify([
56
+ ...this.entries.values()
57
+ ], null, 0), 'utf8');
58
+ writeFileSync(this.filePath, readFileSync(tmp, 'utf8'), 'utf8');
59
+ try {
60
+ require('node:fs').rmSync(tmp);
61
+ } catch {}
62
+ } catch {}
63
+ }
64
+ siteEntries(site) {
65
+ return [
66
+ ...this.entries.values()
67
+ ].filter((e)=>e.key.startsWith(site + ':'));
68
+ }
69
+ lookup(key, domFingerprint, site) {
70
+ this.load();
71
+ const e = this.entries.get(key);
72
+ if (!e) return null;
73
+ if (this.now().getTime() - Date.parse(e.lastHitAt || e.createdAt) > this.ttlMs) {
74
+ this.entries.delete(key);
75
+ this.persist();
76
+ return null;
77
+ }
78
+ if (e.domFingerprint !== domFingerprint) return null;
79
+ e.hits += 1;
80
+ e.lastHitAt = this.now().toISOString();
81
+ this.persist();
82
+ void site;
83
+ return e;
84
+ }
85
+ record(key, domFingerprint, locator, site) {
86
+ this.load();
87
+ const ts = this.now().toISOString();
88
+ const prev = this.entries.get(key);
89
+ this.entries.set(key, {
90
+ key,
91
+ domFingerprint,
92
+ locator,
93
+ createdAt: prev?.createdAt ?? ts,
94
+ lastHitAt: ts,
95
+ hits: prev?.hits ?? 0
96
+ });
97
+ const siteList = this.siteEntries(site);
98
+ if (siteList.length > this.maxPerSite) {
99
+ const victim = siteList.sort((a, b)=>Date.parse(a.lastHitAt) - Date.parse(b.lastHitAt))[0];
100
+ if (victim) this.entries.delete(victim.key);
101
+ }
102
+ this.persist();
103
+ return this.entries.get(key);
104
+ }
105
+ invalidate(key) {
106
+ this.load();
107
+ this.entries.delete(key);
108
+ this.persist();
109
+ }
110
+ size(site) {
111
+ this.load();
112
+ return site ? this.siteEntries(site).length : this.entries.size;
113
+ }
114
+ }
115
+ export function actionCachePath(stateRoot) {
116
+ return join(stateRoot, 'session-action-cache.json');
117
+ }
118
+
119
+
120
+ //# sourceURL=/Users/bytedance/work/gotry/ts/capabilities/session/action-cache.ts
@@ -0,0 +1,83 @@
1
+ const CITY_CODES = {
2
+ 上海: 'sha',
3
+ 北京: 'bjs',
4
+ 广州: 'can',
5
+ 深圳: 'szx',
6
+ 成都: 'ctu',
7
+ 昆明: 'kmg',
8
+ 大理: 'dlu',
9
+ 丽江: 'ljg',
10
+ 西安: 'sia',
11
+ 杭州: 'hgh',
12
+ 三亚: 'syx',
13
+ 厦门: 'xmn',
14
+ 重庆: 'cqg',
15
+ 青岛: 'taa',
16
+ 长沙: 'csx',
17
+ 武汉: 'wuh',
18
+ 南京: 'nkg',
19
+ 郑州: 'cgo',
20
+ 贵阳: 'kwe',
21
+ 桂林: 'kwl',
22
+ 西双版纳: 'jhg',
23
+ 香格里拉: 'dig'
24
+ };
25
+ export function buildEntryUrl(from, to, depDate) {
26
+ const unresolved = [];
27
+ const fromCode = CITY_CODES[from];
28
+ const toCode = CITY_CODES[to];
29
+ if (!fromCode) unresolved.push(from);
30
+ if (!toCode) unresolved.push(to);
31
+ if (unresolved.length > 0 || !/^\d{4}-\d{2}-\d{2}$/.test(depDate)) {
32
+ return {
33
+ ok: false,
34
+ unresolved: unresolved.length > 0 ? unresolved : [
35
+ depDate
36
+ ]
37
+ };
38
+ }
39
+ return {
40
+ ok: true,
41
+ url: `https://flights.ctrip.com/online/list/oneway-${fromCode}-${toCode}?depdate=${depDate}`
42
+ };
43
+ }
44
+ export const NETWORK_HINTS = [
45
+ /search\/api\/search\/batchSearch/
46
+ ];
47
+ export const LOGIN_COOKIE_NAMES = [
48
+ 'cticket',
49
+ 'uid',
50
+ 'uname',
51
+ 'passport'
52
+ ];
53
+ export const SITE_DOMAIN = '.ctrip.com';
54
+ export function parseBatchSearch(body) {
55
+ let raw;
56
+ try {
57
+ raw = JSON.parse(body);
58
+ } catch {
59
+ return [];
60
+ }
61
+ const out = [];
62
+ for (const it of raw.data?.flightItineraryList ?? []){
63
+ const seg = it.flightSegments?.[0];
64
+ const fl = seg?.flightList?.[0];
65
+ if (!fl?.flightNo || !fl.departureDateTime) continue;
66
+ const prices = (it.priceList ?? []).map((p)=>Number(p.adultPrice ?? 0)).filter((n)=>n > 0);
67
+ out.push({
68
+ flightNo: fl.flightNo,
69
+ airline: seg?.airlineName ?? '',
70
+ depDateTime: fl.departureDateTime ?? '',
71
+ arrDateTime: fl.arrivalDateTime ?? '',
72
+ depAirport: fl.departureAirportName ?? '',
73
+ arrAirport: fl.arrivalAirportName ?? '',
74
+ durationMin: Number(seg?.duration ?? 0) || 0,
75
+ price: prices.length > 0 ? Math.min(...prices) : 0,
76
+ aircraft: fl.aircraftName
77
+ });
78
+ }
79
+ return out;
80
+ }
81
+
82
+
83
+ //# sourceURL=/Users/bytedance/work/gotry/ts/capabilities/session/adapters/ctrip-flight.ts
@@ -0,0 +1,73 @@
1
+ const CITY_SLUGS = {
2
+ 上海: 'shanghai',
3
+ 北京: 'beijing',
4
+ 广州: 'guangzhou',
5
+ 深圳: 'shenzhen',
6
+ 成都: 'chengdu',
7
+ 昆明: 'kunming',
8
+ 大理: 'dali',
9
+ 丽江: 'lijiang',
10
+ 西安: 'xian',
11
+ 杭州: 'hangzhou',
12
+ 三亚: 'sanya',
13
+ 厦门: 'xiamen',
14
+ 重庆: 'chongqing',
15
+ 青岛: 'qingdao',
16
+ 长沙: 'changsha',
17
+ 武汉: 'wuhan',
18
+ 南京: 'nanjing',
19
+ 郑州: 'zhengzhou',
20
+ 贵阳: 'guiyang',
21
+ 桂林: 'guilin'
22
+ };
23
+ export const SITE_DOMAIN = 'meituan.com';
24
+ export const LOGIN_COOKIE_NAMES = [
25
+ 'lt',
26
+ 'u',
27
+ 'token',
28
+ 'n'
29
+ ];
30
+ export function buildMeituanEntry(city, vertical, keyword) {
31
+ const slug = CITY_SLUGS[city];
32
+ if (!slug) return {
33
+ ok: false,
34
+ unresolved: [
35
+ city
36
+ ]
37
+ };
38
+ const base = vertical === 'hotel' ? `https://hotel.meituan.com/${slug}/` : `https://minsu.meituan.com/${slug}/`;
39
+ return {
40
+ ok: true,
41
+ url: keyword ? base + `?q=${encodeURIComponent(keyword)}` : base
42
+ };
43
+ }
44
+ export const NETWORK_HINTS = [];
45
+ export function parseMeituanSearch(_body) {
46
+ void _body;
47
+ return [];
48
+ }
49
+ export function extractListings(entries, opts = {}) {
50
+ const out = [];
51
+ const roles = new Set([
52
+ 'heading',
53
+ 'listitem',
54
+ 'link',
55
+ 'article'
56
+ ]);
57
+ for (const e of entries){
58
+ if (out.length >= (opts.max ?? 30)) break;
59
+ if (!roles.has(e.role) || !e.name) continue;
60
+ const pm = /¥\s*(\d+)/.exec(e.name);
61
+ if (!pm) continue;
62
+ const name = e.name.replace(/¥\s*\d+.*$/, '').trim().slice(0, 80);
63
+ if (!name) continue;
64
+ out.push({
65
+ name,
66
+ price: Number(pm[1])
67
+ });
68
+ }
69
+ return out;
70
+ }
71
+
72
+
73
+ //# sourceURL=/Users/bytedance/work/gotry/ts/capabilities/session/adapters/meituan-local.ts
@@ -0,0 +1,40 @@
1
+ export function extractA11yEntries(snapshotText, opts = {}) {
2
+ const cap = opts.maxEntries ?? 500;
3
+ const out = [];
4
+ const lines = snapshotText.split('\n');
5
+ for(let i = 0; i < lines.length && out.length < cap; i++){
6
+ const m = /^[\s*-]*\s*([a-zA-Z][\w-]*)(?:\s+"((?:[^"\\]|\\.)*)")?/.exec(lines[i] ?? '');
7
+ if (!m) continue;
8
+ const name = (m[2] ?? '').replace(/\\"/g, '"').slice(0, 200);
9
+ out.push({
10
+ role: m[1].toLowerCase(),
11
+ name,
12
+ line: i
13
+ });
14
+ }
15
+ return out;
16
+ }
17
+ export function filterSubmitEntries(entries, isSubmit) {
18
+ const interactive = new Set([
19
+ 'button',
20
+ 'link',
21
+ 'menuitem',
22
+ 'option'
23
+ ]);
24
+ return entries.filter((e)=>!(interactive.has(e.role) && isSubmit(e.name)));
25
+ }
26
+ export function nameAffinity(a, b) {
27
+ const x = a.trim();
28
+ const y = b.trim();
29
+ if (!x || !y) return 0;
30
+ if (x === y) return 1;
31
+ if (x.includes(y) || y.includes(x)) return 0.8;
32
+ const sa = new Set(x);
33
+ const sb = new Set(y);
34
+ let common = 0;
35
+ for (const ch of sa)if (sb.has(ch)) common += 1;
36
+ return common / Math.max(sa.size, sb.size);
37
+ }
38
+
39
+
40
+ //# sourceURL=/Users/bytedance/work/gotry/ts/capabilities/session/extract.ts
@@ -0,0 +1,56 @@
1
+ import { mkdirSync, appendFileSync } from 'node:fs';
2
+ import { dirname } from 'node:path';
3
+ const HARD_PAY_RE = /\/(pay|payment|cashier)(\/|\?|$)/i;
4
+ const WRITE_COMPOUND_RE = /(submitorder|createorder|placeorder|cancelorder|refundorder|orderconfirm|orderpay|confirmpay)/i;
5
+ const WRITE_URL_RE = /\/(order|orders|submit|trade|booking|book|purchase|buy|create|cancel|refund)(\/|\?|$)/i;
6
+ const WRITE_METHODS = new Set([
7
+ 'POST',
8
+ 'PUT',
9
+ 'DELETE',
10
+ 'PATCH'
11
+ ]);
12
+ export const SUBMIT_TEXT_RE = /下单|去支付|支付|预订|订票|购买|提交订单|确认订单/;
13
+ export function classifyRequest(method, url) {
14
+ if (HARD_PAY_RE.test(url) || WRITE_COMPOUND_RE.test(url)) return 'block';
15
+ if (WRITE_METHODS.has(method.toUpperCase()) && WRITE_URL_RE.test(url)) return 'block';
16
+ return 'allow';
17
+ }
18
+ export function isSubmitText(text) {
19
+ return SUBMIT_TEXT_RE.test(text ?? '');
20
+ }
21
+ export async function attachReadGuard(ctx, auditPath) {
22
+ let blocked = 0;
23
+ let total = 0;
24
+ await ctx.route('**/*', async (route)=>{
25
+ const req = route.request();
26
+ total += 1;
27
+ const verdict = classifyRequest(req.method(), req.url());
28
+ if (verdict === 'block') {
29
+ blocked += 1;
30
+ if (auditPath) {
31
+ const entry = {
32
+ ts: new Date().toISOString(),
33
+ kind: 'blocked-write-request',
34
+ method: req.method(),
35
+ url: req.url().slice(0, 400)
36
+ };
37
+ try {
38
+ mkdirSync(dirname(auditPath), {
39
+ recursive: true
40
+ });
41
+ appendFileSync(auditPath, JSON.stringify(entry) + '\n');
42
+ } catch {}
43
+ }
44
+ await route.abort('blockedbyclient');
45
+ return;
46
+ }
47
+ await route.continue();
48
+ });
49
+ return {
50
+ blockedCount: ()=>blocked,
51
+ requestCount: ()=>total
52
+ };
53
+ }
54
+
55
+
56
+ //# sourceURL=/Users/bytedance/work/gotry/ts/capabilities/session/read-guard.ts
@@ -0,0 +1,95 @@
1
+ import { chromium } from 'playwright-core';
2
+ import { attachReadGuard } from './read-guard.js';
3
+ export async function openSession(opts = {}) {
4
+ if (opts.mode === 'cdp' || opts.mode !== 'persistent' && !opts.profileDir) {
5
+ try {
6
+ const { homedir: _hd } = await import('node:os');
7
+ const udd = process.env.CHROME_USER_DATA_DIR ?? `${_hd()}/Library/Application Support/Google/Chrome`;
8
+ let endpoint = `http://127.0.0.1:${opts.cdpPort ?? 9222}`;
9
+ try {
10
+ const { readFileSync: _rf } = await import('node:fs');
11
+ const portFile = `${udd}/DevToolsActivePort`;
12
+ const [port, wsPath] = _rf(portFile, 'utf8').trim().split('\n');
13
+ if (port && wsPath?.startsWith('/devtools/browser/')) endpoint = `ws://127.0.0.1:${port.trim()}${wsPath.trim()}`;
14
+ } catch {}
15
+ const browser = await chromium.connectOverCDP(endpoint, {
16
+ timeout: 4_000
17
+ });
18
+ const context = browser.contexts()[0];
19
+ if (!context) {
20
+ await browser.close().catch(()=>{});
21
+ return {
22
+ ok: false,
23
+ summary: 'cdp attached but no default context'
24
+ };
25
+ }
26
+ let guard;
27
+ try {
28
+ guard = await attachReadGuard(context, opts.auditPath);
29
+ } catch (e) {
30
+ await browser.close().catch(()=>{});
31
+ return {
32
+ ok: false,
33
+ summary: `read-guard attach failed: ${e instanceof Error ? e.message.slice(0, 200) : String(e)}`
34
+ };
35
+ }
36
+ const page = context.pages()[0] ?? await context.newPage();
37
+ return {
38
+ ok: true,
39
+ context,
40
+ page,
41
+ guard,
42
+ close: async ()=>{
43
+ await browser.close().catch(()=>{});
44
+ }
45
+ };
46
+ } catch (e) {
47
+ const msg = e instanceof Error ? e.message.slice(0, 160) : String(e);
48
+ return {
49
+ ok: false,
50
+ summary: `cdp attach 失败(日常 Chrome 未开调试端口):${msg}。开启方法:日常 Chrome 打开 chrome://inspect/#remote-debugging → 打开开关并确认弹窗(Chrome 144+)`
51
+ };
52
+ }
53
+ }
54
+ const { homedir } = await import('node:os');
55
+ const profileDir = opts.profileDir ?? `${homedir()}/.gotry/session-profile`;
56
+ let context;
57
+ try {
58
+ context = await chromium.launchPersistentContext(profileDir, {
59
+ channel: 'chrome',
60
+ headless: opts.headless ?? true,
61
+ viewport: {
62
+ width: 1366,
63
+ height: 850
64
+ }
65
+ });
66
+ } catch (e) {
67
+ return {
68
+ ok: false,
69
+ summary: `chrome launch failed: ${e instanceof Error ? e.message.slice(0, 200) : String(e)}`
70
+ };
71
+ }
72
+ let guard;
73
+ try {
74
+ guard = await attachReadGuard(context, opts.auditPath);
75
+ } catch (e) {
76
+ await context.close().catch(()=>{});
77
+ return {
78
+ ok: false,
79
+ summary: `read-guard attach failed: ${e instanceof Error ? e.message.slice(0, 200) : String(e)}`
80
+ };
81
+ }
82
+ const page = context.pages()[0] ?? await context.newPage();
83
+ return {
84
+ ok: true,
85
+ context,
86
+ page,
87
+ guard,
88
+ close: async ()=>{
89
+ await context.close().catch(()=>{});
90
+ }
91
+ };
92
+ }
93
+
94
+
95
+ //# sourceURL=/Users/bytedance/work/gotry/ts/capabilities/session/transport.ts
@@ -0,0 +1,95 @@
1
+ import { openSession } from './session/transport.js';
2
+ import { buildEntryUrl, NETWORK_HINTS, parseBatchSearch, LOGIN_COOKIE_NAMES, SITE_DOMAIN } from './session/adapters/ctrip-flight.js';
3
+ const MIN_INTERVAL_MS = 30_000;
4
+ const lastCallAt = new Map();
5
+ export function __resetRateLimiterForTest() {
6
+ lastCallAt.clear();
7
+ }
8
+ const CHALLENGE_RE = /验证|滑块|captcha|verify/i;
9
+ export async function sessionFlightSearch(q) {
10
+ const started = Date.now();
11
+ const ts = new Date().toISOString();
12
+ const site = 'ctrip-flight';
13
+ const err = (verdict, error)=>({
14
+ ok: false,
15
+ via: 'session-ctrip-flight-error',
16
+ evidence: `[会话:${site}@error@${ts}] ${error}`,
17
+ latencyMs: Date.now() - started,
18
+ verdict,
19
+ error
20
+ });
21
+ const last = lastCallAt.get(site) ?? 0;
22
+ if (Date.now() - last < MIN_INTERVAL_MS) {
23
+ return err('cooldown', `rate limit: last call ${Date.now() - last}ms ago, min ${MIN_INTERVAL_MS}ms`);
24
+ }
25
+ lastCallAt.set(site, Date.now());
26
+ const entry = buildEntryUrl(q.from, q.to, q.date);
27
+ if (!entry.ok || !entry.url) {
28
+ return err('error', `unresolved entry: ${(entry.unresolved ?? []).join('/')} 不在城市码表`);
29
+ }
30
+ const t = await openSession({
31
+ profileDir: q.profileDir,
32
+ headless: q.headless,
33
+ auditPath: q.auditPath,
34
+ mode: q.profileDir ? 'persistent' : 'cdp'
35
+ });
36
+ if (!t.ok) {
37
+ if (q.profileDir === undefined && /cdp attach 失败/.test(t.summary)) return err('needs-attach', t.summary);
38
+ return err('error', t.summary);
39
+ }
40
+ try {
41
+ const loggedIn = async ()=>{
42
+ const cookies = await t.context.cookies([
43
+ `https://flights${SITE_DOMAIN.replace(/^\./, '')}/`
44
+ ]).catch(()=>[]);
45
+ return cookies.some((c)=>LOGIN_COOKIE_NAMES.includes(c.name));
46
+ };
47
+ if (!await loggedIn() && !q.allowAnonymous) {
48
+ return err('needs-login', '匿名实例——先跑 scripts/session-login.ts 用用户自己的账号建立登录态(allowAnonymous 仅限链路自检)');
49
+ }
50
+ let settled = false;
51
+ let body = '';
52
+ const heard = new Promise((resolve)=>{
53
+ t.page.on('response', async (res)=>{
54
+ if (settled) return;
55
+ const u = res.url();
56
+ if (!NETWORK_HINTS.some((re)=>re.test(u))) return;
57
+ try {
58
+ body = await res.text();
59
+ } catch {}
60
+ if (body) {
61
+ settled = true;
62
+ resolve();
63
+ }
64
+ });
65
+ setTimeout(()=>resolve(), q.timeoutMs ?? 25_000);
66
+ });
67
+ await t.page.goto(entry.url, {
68
+ waitUntil: 'domcontentloaded',
69
+ timeout: 30_000
70
+ });
71
+ await heard;
72
+ const title = await t.page.title().catch(()=>'');
73
+ const headHtml = (await t.page.content().catch(()=>'')).slice(0, 5000);
74
+ if (CHALLENGE_RE.test(title + headHtml)) {
75
+ return err('challenged', `风控/验证码命中(title=${title.slice(0, 60)});按红线不重试不绕过,交还用户`);
76
+ }
77
+ const options = parseBatchSearch(body);
78
+ const verdict = options.length > 0 ? 'hit' : 'miss';
79
+ return {
80
+ ok: true,
81
+ via: 'session-ctrip-flight',
82
+ evidence: `[会话:${site}@${ts}] ${options.length} options;guard blocked=${t.guard.blockedCount()}/${t.guard.requestCount()}${q.allowAnonymous ? ';anonymous=自检态' : ''}`,
83
+ latencyMs: Date.now() - started,
84
+ verdict,
85
+ options
86
+ };
87
+ } catch (e) {
88
+ return err('error', e instanceof Error ? e.message.slice(0, 200) : String(e));
89
+ } finally{
90
+ await t.close();
91
+ }
92
+ }
93
+
94
+
95
+ //# sourceURL=/Users/bytedance/work/gotry/ts/capabilities/session-search.ts
@@ -0,0 +1,107 @@
1
+ import { mkdtempSync, rmSync, writeFileSync } from 'node:fs';
2
+ import { tmpdir } from 'node:os';
3
+ import { join } from 'node:path';
4
+ import { ActionCache, actionCachePath, fingerprint, templateKey } from '../capabilities/session/action-cache.js';
5
+ let pass = 0;
6
+ let fail = 0;
7
+ function assert(cond, label, detail) {
8
+ if (cond) {
9
+ pass += 1;
10
+ console.log(` ok - ${label}`);
11
+ } else {
12
+ fail += 1;
13
+ console.log(` FAIL - ${label}${detail !== undefined ? ' :: ' + String(detail) : ''}`);
14
+ }
15
+ }
16
+ const iso = mkdtempSync(join(tmpdir(), 'gotry-actioncache-'));
17
+ const path = actionCachePath(iso);
18
+ const T0 = new Date('2026-08-28T10:00:00Z');
19
+ let clock = T0.getTime();
20
+ const now = ()=>new Date(clock);
21
+ const site = 'ctrip-flight';
22
+ console.log('A. templateKey(参数变量化)');
23
+ const k1 = templateKey(site, 'search', {
24
+ from: '上海',
25
+ to: '丽江',
26
+ date: '2026-10-01'
27
+ });
28
+ const k2 = templateKey(site, 'search', {
29
+ from: '上海',
30
+ to: '丽江',
31
+ date: '2026-11-11'
32
+ });
33
+ assert(k1 === k2 && k1 === 'ctrip-flight:search:上海|丽江|%date%', '同型查询共享模板 key(日期变量化),跨日期复用');
34
+ console.log('B. 命中与指纹失配(被动失效)');
35
+ const c = new ActionCache(path, {
36
+ now
37
+ });
38
+ const fpA = fingerprint('batchSearch v1 shape {"flightItineraryList":[]}');
39
+ const fpB = fingerprint('batchSearch v2 shape {"itineraries":[]}');
40
+ c.record(k1, fpA, {
41
+ entryUrl: 'https://flights.ctrip.com/online/list/oneway-sha-ljg?depdate=%date%',
42
+ parser: 'batchSearch.v1'
43
+ }, site);
44
+ clock += 60_000;
45
+ assert(c.lookup(k1, fpA, site)?.locator.parser === 'batchSearch.v1', '指纹一致 → 命中并回放确定性载荷');
46
+ assert(c.lookup(k1, fpA, site)?.hits === 2, '命中计 hits');
47
+ assert(c.lookup(k1, fpB, site) === null, '接口改版(指纹失配)→ miss 不误放(错误的缓存点击比慢更糟)');
48
+ assert(c.lookup(templateKey(site, 'search', {
49
+ from: '北京',
50
+ to: '大理',
51
+ date: '2026-10-01'
52
+ }), fpA, site) === null, 'key 不同 → miss');
53
+ console.log('C. miss 回写与幂等');
54
+ c.record(k1, fpB, {
55
+ entryUrl: 'oneway-bjs-dlu?depdate=%date%',
56
+ parser: 'batchSearch.v2'
57
+ }, site);
58
+ assert(c.lookup(k1, fpB, site)?.locator.parser === 'batchSearch.v2', '改版后回写新载荷,下次命中');
59
+ const hitsBefore = c.lookup(k1, fpB, site).hits;
60
+ c.record(k1, fpB, {
61
+ parser: 'batchSearch.v2'
62
+ }, site);
63
+ assert(c.lookup(k1, fpB, site).hits === hitsBefore + 1, '重复 record 幂等(hits/createdAt 保留,不重置)');
64
+ console.log('D. TTL 过期(48h 默认;注入时钟)');
65
+ clock += 49 * 3600_000;
66
+ assert(c.lookup(k1, fpB, site) === null && c.size(site) === 0, '超 48h → 过期删除条目');
67
+ console.log('E. LRU 淘汰(每站点上限)');
68
+ const c2 = new ActionCache(path + '.lru', {
69
+ now,
70
+ maxEntriesPerSite: 3
71
+ });
72
+ for(let i = 0; i < 5; i++){
73
+ const key = `ctrip-flight:search:城${i}|丽江|%date%`;
74
+ c2.record(key, fingerprint(`shape-${i}`), {
75
+ i: String(i)
76
+ }, site);
77
+ clock += 1_000;
78
+ }
79
+ assert(c2.size(site) === 3, '超容 LRU 淘汰至 3 条');
80
+ console.log('F. 持久化跨实例 + 损坏文件容错');
81
+ const c3 = new ActionCache(path + '.p', {
82
+ now
83
+ });
84
+ c3.record('meituan:search:大理|民宿|%date%', fingerprint('mt-shape'), {
85
+ url: 'https://www.meituan.com/...'
86
+ }, 'meituan');
87
+ clock += 5_000;
88
+ const c4 = new ActionCache(path + '.p', {
89
+ now
90
+ });
91
+ assert(c4.lookup('meituan:search:大理|民宿|%date%', fingerprint('mt-shape'), 'meituan') !== null, '跨实例:落盘后新实例可命中');
92
+ writeFileSync(path + '.broken', '{not json', 'utf8');
93
+ const c5 = new ActionCache(path + '.broken', {
94
+ now
95
+ });
96
+ assert(c5.size() === 0 && c5.lookup('any', 'any', site) === null, '损坏 JSON 按空缓存,不抛错');
97
+ console.log('G. fingerprint 敏感度');
98
+ assert(fingerprint('abc') !== fingerprint('abd') && fingerprint('abc') === fingerprint('abc'), '微改即变,同文稳定');
99
+ rmSync(iso, {
100
+ recursive: true,
101
+ force: true
102
+ });
103
+ console.log(`\nACTION-CACHE: ${pass} pass, ${fail} fail`);
104
+ process.exit(fail > 0 ? 1 : 0);
105
+
106
+
107
+ //# sourceURL=/Users/bytedance/work/gotry/ts/scripts/action-cache-tests.ts
@@ -26,7 +26,7 @@ import { readUrl } from '../capabilities/agent-reach.js';
26
26
  assert.ok(r.evidence.includes('[agent-reach:web.read@'), '证据链');
27
27
  assert.match(r.evidence, /\[agent-reach:web\.read@2\d{3}-\d{2}-\d{2}T/, '证据链带 ISO 时间戳');
28
28
  assert.ok(typeof r.content === 'string' && r.content.length > 0, 'content 非空');
29
- assert.equal(r.title, 'Example Domain', `Title 提取,实际 ${r.title}`);
29
+ assert.ok(typeof r.title === 'string' && r.title.length > 0, `Title 提取,实际 ${r.title}`);
30
30
  console.log(`3. readUrl example.com → ok (title=${r.title}) OK`);
31
31
  } else {
32
32
  assert.equal(r.via, 'r.jina.ai-error');