@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,81 @@
1
+ import { readFileSync } from 'node:fs';
2
+ import { join } from 'node:path';
3
+ import { extractA11yEntries, filterSubmitEntries, nameAffinity } from '../capabilities/session/extract.js';
4
+ import { buildMeituanEntry, extractListings, parseMeituanSearch } from '../capabilities/session/adapters/meituan-local.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
+ console.log('A. extractA11yEntries(快照形状抽取)');
17
+ const snap = `- button "搜索" [ref=s1e5]
18
+ - textbox "出发城市" [ref=s1e6]
19
+ - heading "上海到丽江机票" [level=1]
20
+ - listitem "吉祥航空 HO5577 ¥1610 起"
21
+ - generic "广告"`;
22
+ const entries = extractA11yEntries(snap);
23
+ assert(entries.length === 5 && entries[0].role === 'button' && entries[0].name === '搜索', 'role+name 提取(ref 行)');
24
+ assert(entries[2].name === '上海到丽江机票' && entries[3].role === 'listitem', 'heading/listitem 归位');
25
+ const noName = extractA11yEntries('- img\n- separator');
26
+ assert(noName.every((e)=>typeof e.name === 'string'), '无 name 行保留空占位不炸');
27
+ assert(extractA11yEntries('').length === 0, '空快照返空');
28
+ console.log('B. filterSubmitEntries(DOM 提交件剔除)');
29
+ const kept = filterSubmitEntries(entries, (n)=>/下单|支付/.test(n));
30
+ assert(kept.length === entries.length, '无提交词:全保留');
31
+ const snap2 = extractA11yEntries('- button "立即下单" [ref=s1e9]\n- button "搜索" [ref=s2e1]');
32
+ assert(filterSubmitEntries(snap2, (n)=>/下单|支付/.test(n)).length === 1, 'button 命中提交黑名单剔除,搜索保留');
33
+ assert(filterSubmitEntries(extractA11yEntries('- heading "支付说明"'), (n)=>/下单|支付/.test(n)).length === 1, '非交互 role(heading)不剔除');
34
+ console.log('C. nameAffinity(自愈候选排序底座)');
35
+ assert(nameAffinity('吉祥航空 HO5577', '吉祥航空 HO5577') === 1, '全等=1');
36
+ assert(nameAffinity('上海到丽江机票', '机票') === 0.8, '包含=0.8');
37
+ assert(nameAffinity('abc', 'xyz') < 0.5, '无关串低分');
38
+ console.log('D. 美团适配器骨架(词表边界/占位合同)');
39
+ const mt = buildMeituanEntry('大理', 'minsu', '洱海');
40
+ assert(mt.ok === true && mt.url === 'https://minsu.meituan.com/dali/?q=%E6%B4%B1%E6%B5%B7', '民宿 entry URL(拼音+关键词编码)');
41
+ assert(buildMeituanEntry('乌兰巴托', 'hotel').ok === false, '词表外城市 unresolved');
42
+ assert(parseMeituanSearch('{"any":1}').length === 0, 'XHR 解析占位:接口实测回填前恒空(miss 语义)');
43
+ console.log('E. extractListings(a11y 兜底:名称+¥价格)');
44
+ const listings = extractListings(entries);
45
+ assert(listings.length === 1 && listings[0].name === '吉祥航空 HO5577' && listings[0].price === 1610, 'listitem 抽名称+价格,截 ¥ 后缀');
46
+ assert(extractListings(extractA11yEntries('- heading "无价格标题"')).length === 0, '无价格行不产出');
47
+ console.log('F. 金标准 20 查询 schema(只增不改语义)');
48
+ const golden = JSON.parse(readFileSync(join(import.meta.dirname, '../data/session-golden-20.json'), 'utf8'));
49
+ assert(golden.queries.length === 20, '恰 20 条');
50
+ assert(new Set(golden.queries.map((q)=>q.id)).size === 20, 'id 唯一');
51
+ const ctripCities = new Set([
52
+ '上海',
53
+ '北京',
54
+ '广州',
55
+ '深圳',
56
+ '成都',
57
+ '昆明',
58
+ '大理',
59
+ '丽江',
60
+ '西安',
61
+ '杭州',
62
+ '三亚',
63
+ '厦门',
64
+ '重庆',
65
+ '青岛',
66
+ '长沙',
67
+ '武汉',
68
+ '南京',
69
+ '郑州',
70
+ '贵阳',
71
+ '桂林',
72
+ '西双版纳',
73
+ '香格里拉'
74
+ ]);
75
+ assert(golden.queries.filter((q)=>q.kind === 'flight').every((q)=>ctripCities.has(q.from) && ctripCities.has(q.to)), 'flight 城市全在双适配器词表内');
76
+ assert(golden.queries.filter((q)=>q.kind.startsWith('meituan') || q.kind.startsWith('flyai')).every((q)=>q.id), '每条带 id');
77
+ console.log(`\nSESSION-EXTRACT: ${pass} pass, ${fail} fail`);
78
+ process.exit(fail > 0 ? 1 : 0);
79
+
80
+
81
+ //# sourceURL=/Users/bytedance/work/gotry/ts/scripts/session-extract-tests.ts
@@ -0,0 +1,48 @@
1
+ import { openSession } from '../capabilities/session/transport.js';
2
+ import { LOGIN_COOKIE_NAMES, SITE_DOMAIN } from '../capabilities/session/adapters/ctrip-flight.js';
3
+ const t = await openSession({
4
+ headless: false
5
+ });
6
+ if (!t.ok) {
7
+ console.error('打开会话失败:', t.summary);
8
+ process.exit(1);
9
+ }
10
+ try {
11
+ await t.page.goto('https://flights.ctrip.com/', {
12
+ waitUntil: 'domcontentloaded',
13
+ timeout: 30_000
14
+ });
15
+ const loggedIn = async ()=>{
16
+ const cookies = await t.context.cookies([
17
+ `https://flights${SITE_DOMAIN.replace(/^\./, '')}/`
18
+ ]).catch(()=>[]);
19
+ return cookies.some((c)=>LOGIN_COOKIE_NAMES.includes(c.name));
20
+ };
21
+ if (await loggedIn()) {
22
+ console.log('已是登录态(~/.gotry/session-profile 持久保存),无需操作。');
23
+ } else {
24
+ console.log('请在打开的窗口中登录携程(手机号+验证码/账密均可)。agent 不会读取或输入任何凭证;登录完成自动检测,最长等 5 分钟...');
25
+ let windowClosed = false;
26
+ t.page.on('close', ()=>{
27
+ windowClosed = true;
28
+ });
29
+ const deadline = Date.now() + 300_000;
30
+ while(Date.now() < deadline && !windowClosed && !await loggedIn()){
31
+ await new Promise((r)=>setTimeout(r, 3_000));
32
+ }
33
+ if (windowClosed) {
34
+ console.log('窗口被提前关闭——登录态未建立。重跑本脚本,等打印「登录态已建立」后再关窗。');
35
+ process.exitCode = 1;
36
+ } else if (await loggedIn()) {
37
+ console.log('登录态已建立并持久化(profile 落在 ~/.gotry/session-profile;后续会话检索直接复用)。');
38
+ } else {
39
+ console.log('超时未检测到登录票据(cookie 名单待校准,或未完成登录)——重跑本脚本即可。');
40
+ process.exitCode = 1;
41
+ }
42
+ }
43
+ } finally{
44
+ await t.close();
45
+ }
46
+
47
+
48
+ //# sourceURL=/Users/bytedance/work/gotry/ts/scripts/session-login.ts
@@ -0,0 +1,162 @@
1
+ import { mkdtempSync, readFileSync, existsSync, rmSync } from 'node:fs';
2
+ import { tmpdir } from 'node:os';
3
+ import { join } from 'node:path';
4
+ import { classifyRequest, isSubmitText } from '../capabilities/session/read-guard.js';
5
+ import { buildEntryUrl, parseBatchSearch } from '../capabilities/session/adapters/ctrip-flight.js';
6
+ import { sessionFlightSearch, __resetRateLimiterForTest } from '../capabilities/session-search.js';
7
+ import { flyaiSearch } from '../capabilities/flyai.js';
8
+ let pass = 0;
9
+ let fail = 0;
10
+ function assert(cond, label, detail) {
11
+ if (cond) {
12
+ pass += 1;
13
+ console.log(` ok - ${label}`);
14
+ } else {
15
+ fail += 1;
16
+ console.log(` FAIL - ${label}${detail !== undefined ? ' :: ' + JSON.stringify(detail)?.slice(0, 300) : ''}`);
17
+ }
18
+ }
19
+ console.log('A. classifyRequest(方法×URL 双因子)');
20
+ assert(classifyRequest('POST', 'https://flights.ctrip.com/international/search/api/search/batchSearch?v=1') === 'allow', '搜索 POST(batchSearch)放行');
21
+ assert(classifyRequest('POST', 'https://m.ctrip.com/restapi/soa2/12345/SubmitOrder') === 'block', 'POST /SubmitOrder 拦截(驼峰复合)');
22
+ assert(classifyRequest('POST', 'https://m.ctrip.com/restapi/soa2/12345/createOrder') === 'block', 'POST /createOrder 拦截(驼峰复合)');
23
+ assert(classifyRequest('GET', 'https://pay.ctrip.com/pay/payment?x=1') === 'block', 'GET 硬支付模式拦截(任意方法)');
24
+ assert(classifyRequest('GET', 'https://m.ctrip.com/restapi/soa2/15095/SearchBoxRecommend') === 'allow', 'GET 搜索建议放行');
25
+ assert(classifyRequest('PUT', 'https://api.example.com/trade/create') === 'block', 'PUT /trade/create 拦截');
26
+ assert(classifyRequest('OPTIONS', 'https://flights.ctrip.com/api/preflight') === 'allow', 'OPTIONS 放行');
27
+ console.log('B. parseBatchSearch(fixture)');
28
+ const fixture = JSON.stringify({
29
+ status: 0,
30
+ data: {
31
+ flightItineraryList: [
32
+ {
33
+ flightSegments: [
34
+ {
35
+ airlineName: '吉祥航空',
36
+ duration: 200,
37
+ flightList: [
38
+ {
39
+ flightNo: 'HO5577',
40
+ departureAirportName: '虹桥国际机场',
41
+ arrivalAirportName: '三义机场',
42
+ departureDateTime: '2026-10-01 07:35:00',
43
+ arrivalDateTime: '2026-10-01 10:55:00',
44
+ aircraftName: '波音737(中)'
45
+ }
46
+ ]
47
+ }
48
+ ],
49
+ priceList: [
50
+ {
51
+ adultPrice: 3240
52
+ },
53
+ {
54
+ adultPrice: 2980
55
+ }
56
+ ]
57
+ },
58
+ {
59
+ flightSegments: [
60
+ {}
61
+ ]
62
+ }
63
+ ]
64
+ }
65
+ });
66
+ const parsed = parseBatchSearch(fixture);
67
+ assert(parsed.length === 1, 'malformed 项跳过,1 个有效行程');
68
+ assert(parsed[0]?.flightNo === 'HO5577' && parsed[0]?.price === 2980, 'flightNo + priceList 最小 adultPrice');
69
+ assert(parsed[0]?.depDateTime === '2026-10-01 07:35:00' && parsed[0]?.durationMin === 200, '时刻与时长字段');
70
+ assert(parseBatchSearch('not-json').length === 0, '非 JSON 返空不抛错');
71
+ console.log('C. buildEntryUrl(城市码表)');
72
+ const e1 = buildEntryUrl('上海', '丽江', '2026-10-01');
73
+ assert(e1.ok === true && e1.url === 'https://flights.ctrip.com/online/list/oneway-sha-ljg?depdate=2026-10-01', '上海→丽江 URL 精确');
74
+ const e2 = buildEntryUrl('乌兰巴托', '丽江', '2026-10-01');
75
+ assert(e2.ok === false && e2.unresolved?.[0] === '乌兰巴托', '词表外城市 unresolved 逐字保留(不猜)');
76
+ assert(buildEntryUrl('上海', '丽江', '十月一号').ok === false, '非法日期拒绝');
77
+ console.log('D. isSubmitText(DOM 提交件黑名单)');
78
+ assert(isSubmitText('立即下单') && isSubmitText('去支付') && isSubmitText('提交订单'), '下单/支付/提交 命中');
79
+ assert(!isSubmitText('搜索') && !isSubmitText('筛选'), '搜索/筛选 不误伤');
80
+ console.log('E. 节律闸(≥30s 间隔)');
81
+ __resetRateLimiterForTest();
82
+ const r1 = await sessionFlightSearch({
83
+ from: '乌兰巴托',
84
+ to: '丽江',
85
+ date: '2026-10-01'
86
+ });
87
+ const r2 = await sessionFlightSearch({
88
+ from: '上海',
89
+ to: '丽江',
90
+ date: '2026-10-01'
91
+ });
92
+ assert(r1.verdict === 'error' && /unresolved/.test(r1.error ?? ''), '首次调用:词表外 → error(unresolved)');
93
+ assert(r2.verdict === 'cooldown', '30s 内二次调用 → cooldown,不发起导航');
94
+ __resetRateLimiterForTest();
95
+ console.log('F. flyaiSearch(live,飞猪官方,无 key)');
96
+ const fr = await flyaiSearch({
97
+ kind: 'flight',
98
+ origin: '上海',
99
+ destination: '丽江',
100
+ depDate: '2026-10-01'
101
+ });
102
+ const sentinelBlocked = fr.verdict === 'error' && /sentinel|block/i.test(fr.error ?? '');
103
+ if (sentinelBlocked) {
104
+ console.log(' WARN - 飞猪 Sentinel 限流(2026-08-28 实测,配额未文档化)——降级合同验证通过,跳过 hit 断言');
105
+ assert(fr.ok === false && /\[实时API:flyai@error@/.test(fr.evidence), '限流降级:结构化 error + 证据链错误形');
106
+ } else {
107
+ assert(fr.ok === true && fr.verdict === 'hit', '上海→丽江 hit', fr);
108
+ assert((fr.options?.length ?? 0) >= 1 && (fr.options?.every((o)=>o.price > 0 && /^\d+[A-Z]\d+|^[A-Z]{2}\d+/.test(o.no)) ?? false), '结构化字段齐(price>0,航班号形)', fr.options?.[0]);
109
+ }
110
+ assert(/\[实时API:flyai/.test(fr.evidence), '证据链 [实时API:flyai@*]');
111
+ console.log('G. sessionFlightSearch(live,隔离 profile + ReadGuard)');
112
+ if (process.env.GOTRY_SESSION_LIVE === '0') {
113
+ console.log(' SKIP - GOTRY_SESSION_LIVE=0');
114
+ } else {
115
+ __resetRateLimiterForTest();
116
+ const iso = mkdtempSync(join(tmpdir(), 'gotry-session-test-'));
117
+ const nl = await sessionFlightSearch({
118
+ from: '上海',
119
+ to: '丽江',
120
+ date: '2026-10-01',
121
+ profileDir: join(iso, 'anon-profile'),
122
+ headless: true
123
+ });
124
+ assert(nl.verdict === 'needs-login' && nl.ok === false, '匿名实例 → needs-login(不导航不发请求)', nl);
125
+ __resetRateLimiterForTest();
126
+ const sr = await sessionFlightSearch({
127
+ from: '上海',
128
+ to: '丽江',
129
+ date: '2026-10-01',
130
+ profileDir: join(iso, 'profile'),
131
+ headless: false,
132
+ allowAnonymous: true,
133
+ auditPath: join(iso, 'audit', 'session-incidents.jsonl')
134
+ });
135
+ if (sr.verdict === 'challenged') {
136
+ console.log(` WARN - 风控命中(平台方差,合同路径验证通过):${sr.error}`);
137
+ assert(sr.ok === false && sr.verdict === 'challenged', 'challenged = degraded 语义正确(不重试不绕过)');
138
+ } else if (sr.verdict === 'error' && /chrome launch failed/.test(sr.error ?? '')) {
139
+ console.log(' SKIP - 本机无 Chrome(channel:chrome 不可用)');
140
+ } else {
141
+ assert(sr.ok === true && sr.verdict === 'hit' && (sr.options?.length ?? 0) >= 1, '上海→丽江 会话嗅探 hit', sr);
142
+ assert(sr.options?.every((o)=>o.price > 0 && o.depDateTime.includes('2026-10-01')) ?? false, '班期=查询日,价格>0', sr.options?.[0]);
143
+ assert(/\[会话:ctrip-flight@/.test(sr.evidence) && /blocked=0/.test(sr.evidence), '证据链 [会话:*] + ReadGuard 零拦截(纯只读)');
144
+ assert(!existsSync(join(iso, 'audit', 'session-incidents.jsonl')), '审计文件不出现(零写请求)');
145
+ const flyaiMin = Math.min(...fr.options?.map((o)=>o.price).filter((p)=>p > 0) ?? [
146
+ 0
147
+ ]);
148
+ const sessionMin = Math.min(...sr.options?.map((o)=>o.price).filter((p)=>p > 0) ?? [
149
+ 0
150
+ ]);
151
+ console.log(` 双源对照:FlyAI 最低 ¥${flyaiMin} vs 会话(携程)最低 ¥${sessionMin}(同日同线路,记录不判等)`);
152
+ }
153
+ rmSync(iso, {
154
+ recursive: true,
155
+ force: true
156
+ });
157
+ }
158
+ console.log(`\nSESSION P1: ${pass} pass, ${fail} fail`);
159
+ process.exit(fail > 0 ? 1 : 0);
160
+
161
+
162
+ //# sourceURL=/Users/bytedance/work/gotry/ts/scripts/session-tests.ts
@@ -2,6 +2,9 @@ import { readFile } from 'node:fs/promises';
2
2
  import { join } from 'node:path';
3
3
  import { apply } from '../src/index.js';
4
4
  async function main() {
5
+ const { mkdtempSync, rmSync } = await import('node:fs');
6
+ const { tmpdir } = await import('node:os');
7
+ const smokeRoot = mkdtempSync(join(tmpdir(), 'gotry-smoke-'));
5
8
  const registered = [];
6
9
  const variables = {};
7
10
  const ctx = {
@@ -15,7 +18,7 @@ async function main() {
15
18
  }
16
19
  };
17
20
  apply(ctx, {
18
- stateRoot: '.',
21
+ stateRoot: smokeRoot,
19
22
  timeoutMs: 30_000,
20
23
  hbcliBin: 'hbcli-not-on-path'
21
24
  });
@@ -277,6 +280,43 @@ async function main() {
277
280
  }
278
281
  console.log('memory read-back: motivation_brief renders profile for returning sessions (empty = first visit)');
279
282
  }
283
+ {
284
+ const fa = await byName('gotry_flyai_search').execute({
285
+ query: {
286
+ kind: 'flight',
287
+ from: '上海',
288
+ to: '丽江',
289
+ date: '2026-10-01'
290
+ }
291
+ }, null);
292
+ const faBlocked = fa.verdict === 'error' && /sentinel|block/i.test(fa.error ?? '');
293
+ if (faBlocked) {
294
+ console.log(' WARN - flyai Sentinel 限流中,降级合同通过(hit 断言跳过)');
295
+ } else if (fa.ok !== true || fa.verdict !== 'hit' || (fa.options?.length ?? 0) < 1 || !/\[实时API:flyai@/.test(fa.evidence ?? '')) {
296
+ throw new Error(`FAIL: flyai 工具应 live hit,实际:${JSON.stringify(fa).slice(0, 200)}`);
297
+ }
298
+ const prof = mkdtempSync(join(smokeRoot, 'sess-'));
299
+ const ss = await byName('gotry_session_search').execute({
300
+ query: {
301
+ from: '上海',
302
+ to: '丽江',
303
+ date: '2026-10-01'
304
+ }
305
+ }, 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)}`);
312
+ }
313
+ console.log(`session-face tools: flyai ${faBlocked ? 'sentinel-限流降级' : `live hit(${fa.options?.length ?? 0} 条)`}; session 终态=${ss.verdict}(登录态存在前提合同)`);
314
+ }
315
+ rmSync(smokeRoot, {
316
+ recursive: true,
317
+ force: true
318
+ });
319
+ console.log(`smoke state cleaned: ${smokeRoot}`);
280
320
  console.log('\nSMOKE OK');
281
321
  }
282
322
  main().catch((e)=>{
@@ -0,0 +1,136 @@
1
+ import { existsSync, mkdtempSync, mkdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
2
+ import { tmpdir } from 'node:os';
3
+ import { join } from 'node:path';
4
+ import { spawnSync } from 'node:child_process';
5
+ let pass = 0;
6
+ let fail = 0;
7
+ function assert(cond, msg) {
8
+ if (cond) {
9
+ pass++;
10
+ console.log(` ok - ${msg}`);
11
+ } else {
12
+ fail++;
13
+ console.error(` FAIL - ${msg}`);
14
+ }
15
+ }
16
+ function cli(args) {
17
+ return spawnSync('npx', [
18
+ 'tsx',
19
+ 'scripts/state-cli.ts',
20
+ ...args
21
+ ], {
22
+ encoding: 'utf-8'
23
+ });
24
+ }
25
+ const root = mkdtempSync(join(tmpdir(), 'gotry-cli-'));
26
+ const dir = join(root, 'gotry-state');
27
+ mkdirSync(dir, {
28
+ recursive: true
29
+ });
30
+ writeFileSync(join(dir, 'motivation-profile.json'), JSON.stringify({
31
+ weights: {
32
+ curiosity: 0.6
33
+ },
34
+ evidence: [
35
+ '原话A'
36
+ ],
37
+ hard: {}
38
+ }));
39
+ writeFileSync(join(dir, 'wish-pool.json'), JSON.stringify([
40
+ {
41
+ wish_id: 'wLEGACY1',
42
+ name: '京都',
43
+ conditions: {
44
+ days: 7
45
+ },
46
+ added_at: '2026-08-01T00:00:00Z'
47
+ }
48
+ ]));
49
+ const mig = cli([
50
+ 'migrate',
51
+ root
52
+ ]);
53
+ assert(mig.status === 0 && /账本就绪/.test(mig.stdout ?? ''), `migrate exit 0 且报告就绪(实际 ${mig.status}:${(mig.stderr ?? '').slice(0, 200)})`);
54
+ assert(existsSync(join(dir, 'gotry-state.db')), 'migrate 后账本文件存在');
55
+ assert(existsSync(join(dir, 'pre-ledger-backup', 'wish-pool.json')), 'migrate 前自动快照(pre-ledger-backup/)');
56
+ const stats = cli([
57
+ 'stats',
58
+ root
59
+ ]);
60
+ assert(stats.status === 0 && /wishes=1/.test(stats.stdout ?? '') && /events=\d+/.test(stats.stdout ?? ''), `stats 计数正确(${(stats.stdout ?? '').trim()})`);
61
+ const log = cli([
62
+ 'log',
63
+ root,
64
+ '--limit',
65
+ '3'
66
+ ]);
67
+ assert(log.status === 0 && /\bwish\.imported\b|\bmotivation\.imported\b/.test(log.stdout ?? ''), `log 呈现账本事件(append-only 审计面)`);
68
+ rmSync(join(dir, 'wish-pool.json'));
69
+ const exp = cli([
70
+ 'export',
71
+ root
72
+ ]);
73
+ assert(exp.status === 0 && existsSync(join(dir, 'wish-pool.json')), 'export 重建旧文件名视图(红线 6:可见可导出)');
74
+ const exported = JSON.parse(readFileSync(join(dir, 'wish-pool.json'), 'utf-8'));
75
+ assert(exported[0]?.wish_id === 'wLEGACY1', 'export 视图内容与账本一致');
76
+ const fg = cli([
77
+ 'forget',
78
+ root,
79
+ 'wish',
80
+ 'wLEGACY1'
81
+ ]);
82
+ assert(fg.status === 0 && /物理硬删/.test(fg.stdout ?? ''), 'forget 执行成功');
83
+ const stats2 = cli([
84
+ 'stats',
85
+ root
86
+ ]);
87
+ assert(/wishes=0/.test(stats2.stdout ?? ''), 'forget 后愿望池归零(红线 6「可删除」,D5 物理硬删)');
88
+ const log2 = cli([
89
+ 'log',
90
+ root,
91
+ '--limit',
92
+ '3'
93
+ ]);
94
+ assert(/forget\.executed/.test(log2.stdout ?? ''), 'forget 留审计一行(删除本身可溯源)');
95
+ const root2 = mkdtempSync(join(tmpdir(), 'gotry-cli2-'));
96
+ const req = cli([
97
+ 'pw-request',
98
+ root2,
99
+ 'booking:e2e-1',
100
+ 'flight-order-confirm',
101
+ '{"flight":"MU123"}'
102
+ ]);
103
+ assert(req.status === 0 && /"created":true/.test(req.stdout ?? ''), 'pw-request 登记成功(L2:只登记不执行)');
104
+ const dup = cli([
105
+ 'pw-request',
106
+ root2,
107
+ 'booking:e2e-1',
108
+ 'flight-order-confirm',
109
+ '{"flight":"MU123"}'
110
+ ]);
111
+ assert(/"created":false/.test(dup.stdout ?? ''), 'pw-request 幂等键去重');
112
+ const conf = cli([
113
+ 'pw-confirm',
114
+ root2,
115
+ 'booking:e2e-1',
116
+ 'PNR-XYZ'
117
+ ]);
118
+ assert(conf.status === 0 && /"ok":true/.test(conf.stdout ?? ''), 'pw-confirm 携 receipt 确认(L3)');
119
+ const list = cli([
120
+ 'pw-list',
121
+ root2
122
+ ]);
123
+ assert(/confirmed/.test(list.stdout ?? '') && /PNR-XYZ/.test(list.stdout ?? ''), 'pw-list 呈现终态与 receipt');
124
+ rmSync(root, {
125
+ recursive: true,
126
+ force: true
127
+ });
128
+ rmSync(root2, {
129
+ recursive: true,
130
+ force: true
131
+ });
132
+ console.log(`\nSTATE-CLI TESTS: ${pass} ok, ${fail} fail`);
133
+ if (fail > 0) process.exit(1);
134
+
135
+
136
+ //# sourceURL=/Users/bytedance/work/gotry/ts/scripts/state-cli-tests.ts