@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,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
@@ -1,18 +1,37 @@
1
- import { loadAsyncTicket, settleAsyncTicket, collectDeepPlanning } from '../src/loop.js';
1
+ import { appendFileSync } from 'node:fs';
2
+ import { collectDeepPlanning, loadAsyncTicket, makeJournaledSolvePort, settleAsyncTicket } from '../src/loop.js';
2
3
  import { solveUnified } from '../src/unified.js';
4
+ import { openLedgerIfExists } from '../src/state-ledger.js';
3
5
  const ticketId = process.argv[2];
6
+ const stateRoot = process.argv[3] ?? '.';
4
7
  if (!ticketId) {
5
- console.error('用法:npx tsx scripts/async-collect.ts <ticketId>(工单在 gotry-state/async/)');
8
+ console.error('用法:npx tsx scripts/async-collect.ts <ticketId> [stateRoot](权威在账本 workflow_runs,视图在 gotry-state/async/)');
6
9
  process.exit(1);
7
10
  }
8
- const loaded = await loadAsyncTicket(ticketId);
11
+ const loaded = await loadAsyncTicket(ticketId, stateRoot);
9
12
  if (!loaded) {
10
- console.error(`工单 ${ticketId} 不存在(gotry-state/async/${ticketId}.json)`);
13
+ console.error(`工单 ${ticketId} 不存在(账本 workflow_runs / gotry-state/async/${ticketId}.json)`);
11
14
  process.exit(1);
12
15
  }
13
- const { reply } = await collectDeepPlanning(loaded.state, loaded.ticket, solveUnified);
14
- const out = await settleAsyncTicket(ticketId, reply);
15
- console.log(`交付物已落盘:ts/${out}\n\n${reply}`);
16
+ const ledger = openLedgerIfExists(stateRoot);
17
+ const run = ledger?.getWorkflowRun(ticketId);
18
+ if (run?.status === 'settled' && run.deliverable) {
19
+ console.log(`工单 ${ticketId} 已交付(账本终态,复诵不重算):\n\n${run.deliverable}`);
20
+ process.exit(0);
21
+ }
22
+ if (!ledger) {
23
+ console.error(`工单 ${ticketId}:stateRoot(${stateRoot})无账本——持久化先于回收,不应发生`);
24
+ process.exit(1);
25
+ }
26
+ const solve = makeJournaledSolvePort(ledger, ticketId, solveUnified, {
27
+ onRealSolve: ()=>{
28
+ const f = process.env['GOTRY_SOLVE_COUNT_FILE'];
29
+ if (f) appendFileSync(f, 'solve\n');
30
+ }
31
+ });
32
+ const { reply } = await collectDeepPlanning(loaded.state, loaded.ticket, solve);
33
+ const out = await settleAsyncTicket(ticketId, reply, stateRoot);
34
+ console.log(`交付物已落盘:${out}\n\n${reply}`);
16
35
 
17
36
 
18
37
  //# sourceURL=/Users/bytedance/work/gotry/ts/scripts/async-collect.ts
@@ -0,0 +1,112 @@
1
+ import assert from 'node:assert/strict';
2
+ import { companionId, sensitiveViolation, upsertCompanion } from '../src/companions.js';
3
+ let n = 0;
4
+ function pass(name, body) {
5
+ body();
6
+ console.log(` ${++n}. ${name} OK`);
7
+ }
8
+ pass('负面清单守卫:证件号/手机号拒收入库(红线 6 工程形态)', ()=>{
9
+ assert.ok(sensitiveViolation('护照号 E12345678')?.includes('负面清单'), '证件拒');
10
+ assert.ok(sensitiveViolation('我电话 13812345678')?.includes('负面清单'), '手机号拒');
11
+ assert.equal(sensitiveViolation('爸爸65,轻度高血压,走不动山路'), null, '行为约束正常');
12
+ const r = upsertCompanion([], {
13
+ label: '爸爸',
14
+ constraints: {
15
+ health: [
16
+ '轻度高血压'
17
+ ]
18
+ },
19
+ evidence: '身份证号 110101199001011234,轻度高血压'
20
+ });
21
+ assert.equal(r.appended, false);
22
+ assert.ok(r.reason?.includes('负面清单'));
23
+ });
24
+ pass('新建:约束+证据落库,companion_id 语义派生', ()=>{
25
+ const r = upsertCompanion([], {
26
+ label: '爸 爸',
27
+ constraints: {
28
+ health: [
29
+ '轻度高血压'
30
+ ],
31
+ mobility: '步行≤4h'
32
+ },
33
+ evidence: '爸爸65有轻度高血压,别太累'
34
+ });
35
+ assert.equal(r.appended, true);
36
+ assert.equal(r.companionId, '爸爸');
37
+ const p = r.profiles[0];
38
+ assert.deepEqual(p.constraints.health, [
39
+ '轻度高血压'
40
+ ]);
41
+ assert.deepEqual(p.evidence, [
42
+ '爸爸65有轻度高血压,别太累'
43
+ ]);
44
+ });
45
+ pass('upsert 合并:数组追加不删史/覆盖取新/幂等', ()=>{
46
+ let ps = [];
47
+ ps = upsertCompanion(ps, {
48
+ label: '爸爸',
49
+ constraints: {
50
+ health: [
51
+ '轻度高血压'
52
+ ],
53
+ mobility: '步行≤4h'
54
+ },
55
+ evidence: '原话一'
56
+ }).profiles;
57
+ ps = upsertCompanion(ps, {
58
+ label: '爸爸',
59
+ constraints: {
60
+ health: [
61
+ '晕车'
62
+ ],
63
+ mobility: '步行≤3h'
64
+ },
65
+ evidence: '原话二:晕车'
66
+ }).profiles;
67
+ const p = ps.find((x)=>x.companion_id === '爸爸');
68
+ assert.deepEqual(p.constraints.health, [
69
+ '轻度高血压',
70
+ '晕车'
71
+ ], '数组追加不删史');
72
+ assert.equal(p.constraints.mobility, '步行≤3h', '标量取新值');
73
+ assert.equal(p.evidence.length, 2, '证据两条');
74
+ const r3 = upsertCompanion(ps, {
75
+ label: '爸爸',
76
+ constraints: {
77
+ health: [
78
+ '晕车'
79
+ ],
80
+ mobility: '步行≤3h'
81
+ },
82
+ evidence: '原话二:晕车'
83
+ });
84
+ assert.equal(r3.appended, false, '全量相同幂等 no-op');
85
+ });
86
+ pass('多人并存与 id 稳定性', ()=>{
87
+ let ps = [];
88
+ ps = upsertCompanion(ps, {
89
+ label: '爸爸',
90
+ constraints: {
91
+ health: [
92
+ '高血压'
93
+ ]
94
+ },
95
+ evidence: 'a'
96
+ }).profiles;
97
+ ps = upsertCompanion(ps, {
98
+ label: '女朋友',
99
+ constraints: {
100
+ prefs: [
101
+ '怕吵'
102
+ ]
103
+ },
104
+ evidence: 'b'
105
+ }).profiles;
106
+ assert.equal(ps.length, 2);
107
+ assert.equal(companionId('女 朋友'), '女朋友');
108
+ });
109
+ console.log(`\nCOMPANION TESTS: ${n}/4 OK(memory-design P2 守门面)`);
110
+
111
+
112
+ //# sourceURL=/Users/bytedance/work/gotry/ts/scripts/companion-tests.ts