@danceiny/gotry 0.0.1-rc.14 → 0.0.1-rc.16
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.
- package/README.md +26 -20
- package/README.zh-CN.md +27 -21
- package/bin/gotry-bootstrap.js +331 -3
- package/cordis.gotry-patch.yml +10 -0
- package/data/airline-airports.json +39 -0
- package/dist/capabilities/agent-reach-deep.js +1 -1
- package/dist/capabilities/agent-reach.js +1 -1
- package/dist/capabilities/anything.js +1 -1
- package/dist/capabilities/artifacts.js +217 -0
- package/dist/capabilities/effect.js +293 -0
- package/dist/capabilities/fact-log.js +41 -0
- package/dist/capabilities/flyai.js +1 -1
- package/dist/capabilities/hbcli.js +43 -1
- package/dist/capabilities/incident-log.js +1 -1
- package/dist/capabilities/opensky.js +1 -1
- package/dist/capabilities/resilience.js +90 -0
- package/dist/capabilities/session/action-cache.js +1 -1
- package/dist/capabilities/session/adapters/ctrip-flight.js +1 -1
- package/dist/capabilities/session/adapters/meituan-local.js +1 -1
- package/dist/capabilities/session/benchmark.js +2 -1
- package/dist/capabilities/session/extension-bridge.js +345 -0
- package/dist/capabilities/session/extension-channel.js +93 -0
- package/dist/capabilities/session/extract.js +1 -1
- package/dist/capabilities/session/health-watch.js +216 -0
- package/dist/capabilities/session/read-guard.js +1 -1
- package/dist/capabilities/session/transport.js +1 -1
- package/dist/capabilities/session/wizard.js +377 -0
- package/dist/capabilities/session-consent.js +1 -1
- package/dist/capabilities/session-login.js +77 -1
- package/dist/capabilities/session-search.js +69 -2
- package/dist/capabilities/weather.js +1 -1
- package/dist/scripts/action-cache-tests.js +1 -1
- package/dist/scripts/agent-reach-deep-tests.js +1 -1
- package/dist/scripts/agent-reach-tests.js +1 -1
- package/dist/scripts/agent-reach-wrapper-tests.js +1 -1
- package/dist/scripts/anything-tests.js +1 -1
- package/dist/scripts/async-collect.js +1 -1
- package/dist/scripts/booking-saga-tests.js +187 -0
- package/dist/scripts/bootstrap-tests.js +49 -3
- package/dist/scripts/build-changelog.js +279 -0
- package/dist/scripts/changelog-tests.js +165 -0
- package/dist/scripts/companion-tests.js +1 -1
- package/dist/scripts/diff-test.js +1 -1
- package/dist/scripts/effect-tests.js +474 -0
- package/dist/scripts/engine-run.js +1 -1
- package/dist/scripts/engine-tests.js +1 -1
- package/dist/scripts/extension-tests.js +468 -0
- package/dist/scripts/fact-gate-tests.js +318 -0
- package/dist/scripts/flyai-tests.js +1 -1
- package/dist/scripts/hbcli-e2e-tests.js +180 -0
- package/dist/scripts/hbcli-tests.js +1 -1
- package/dist/scripts/health-watch-cli.js +51 -0
- package/dist/scripts/i18n-tests.js +1 -1
- package/dist/scripts/incident-tests.js +1 -1
- package/dist/scripts/journey-tests.js +1 -1
- package/dist/scripts/ledger-tests.js +1 -1
- package/dist/scripts/ledger-workflow-crash.js +1 -1
- package/dist/scripts/memory-capture-tests.js +1 -1
- package/dist/scripts/memory-decay-tests.js +1 -1
- package/dist/scripts/memory-metrics.js +1 -1
- package/dist/scripts/memory-value-report.js +1 -1
- package/dist/scripts/nightly-evidence-tests.js +186 -0
- package/dist/scripts/nightly-evidence.js +289 -0
- package/dist/scripts/nudge-digest.js +1 -1
- package/dist/scripts/onboarding-tests.js +218 -0
- package/dist/scripts/opensky-check.js +1 -1
- package/dist/scripts/opensky-tests.js +1 -1
- package/dist/scripts/price-drift-tests.js +441 -0
- package/dist/scripts/price-drift-watch.js +491 -0
- package/dist/scripts/probe-poi-tests.js +1 -1
- package/dist/scripts/product-metrics.js +1 -1
- package/dist/scripts/publish-preverify.js +1 -1
- package/dist/scripts/realtime-pricing-tests.js +1 -1
- package/dist/scripts/replay-async.js +1 -1
- package/dist/scripts/replay-real.js +1 -1
- package/dist/scripts/replay.js +1 -1
- package/dist/scripts/session-attach-diagnose.js +1 -1
- package/dist/scripts/session-attach-poc.js +1 -1
- package/dist/scripts/session-benchmark.js +1 -1
- package/dist/scripts/session-extract-tests.js +1 -1
- package/dist/scripts/session-login.js +1 -1
- package/dist/scripts/session-tests.js +1 -1
- package/dist/scripts/skeleton-check.js +1 -1
- package/dist/scripts/skeleton-integration-test.js +1 -1
- package/dist/scripts/skills-contract-tests.js +1 -1
- package/dist/scripts/smoke.js +159 -1
- package/dist/scripts/state-cli-tests.js +1 -1
- package/dist/scripts/state-cli.js +1 -1
- package/dist/scripts/time-eval-tests.js +1 -1
- package/dist/scripts/travel-timeline-tests.js +1 -1
- package/dist/scripts/unified-tests.js +1 -1
- package/dist/scripts/weather-tests.js +1 -1
- package/dist/scripts/wizard-bootstrap.js +32 -0
- package/dist/scripts/z3-race-tests.js +1 -1
- package/dist/src/artifact-gate.js +310 -0
- package/dist/src/bookable-facts.js +349 -0
- package/dist/src/booking-saga.js +153 -0
- package/dist/src/bridge.js +1 -1
- package/dist/src/companions.js +1 -1
- package/dist/src/contracts.js +1 -1
- package/dist/src/dsh-llm.js +31 -7
- package/dist/src/engine.js +1 -1
- package/dist/src/i18n.js +1 -1
- package/dist/src/index.js +322 -46
- package/dist/src/journey.js +1 -1
- package/dist/src/loop.js +1 -1
- package/dist/src/memory-capture.js +1 -1
- package/dist/src/memory-decay.js +1 -1
- package/dist/src/memory-utility.js +1 -1
- package/dist/src/mock-llm.js +1 -1
- package/dist/src/model.js +1 -1
- package/dist/src/realtime-pricing.js +14 -7
- package/dist/src/slot-spec.js +1 -1
- package/dist/src/state-ledger.js +1 -1
- package/dist/src/time-anchor.js +1 -1
- package/dist/src/tool-packet.js +1 -1
- package/dist/src/travel-slots.js +1 -1
- package/dist/src/travel-timeline.js +1 -1
- package/dist/src/unified.js +1 -1
- package/dist/src/wish-pool.js +1 -1
- package/dist/src/z3-shared.js +1 -1
- package/extension/README.md +26 -0
- package/extension/background.js +160 -0
- package/extension/content-bridge.js +41 -0
- package/extension/content-main.js +62 -0
- package/extension/manifest.json +32 -0
- package/package.json +8 -2
- package/ts/capabilities/artifacts.ts +235 -0
- package/ts/capabilities/fact-log.ts +54 -0
- package/ts/capabilities/hbcli.ts +46 -0
- package/ts/capabilities/session/benchmark.ts +4 -1
- package/ts/capabilities/session/extension-bridge.ts +354 -0
- package/ts/capabilities/session/extension-channel.ts +92 -0
- package/ts/capabilities/session/health-watch.ts +257 -0
- package/ts/capabilities/session/wizard.ts +282 -0
- package/ts/capabilities/session-login.ts +53 -2
- package/ts/capabilities/session-search.ts +68 -3
- package/ts/src/artifact-gate.ts +365 -0
- package/ts/src/bookable-facts.ts +557 -0
- package/ts/src/booking-saga.ts +133 -0
- package/ts/src/dsh-llm.ts +40 -3
- package/ts/src/index.ts +230 -28
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
# GoTry Session Bridge(浏览器扩展)
|
|
2
|
+
|
|
3
|
+
一次性安装的会话检索数据面(issue #21 传输层方案 C,2026-08-29 founder 定案):在你**自己的** Chrome 登录态里,只读嗅探携程机票检索回包([会话:ctrip-flight]。gotry 不再需要逐连接弹 Chrome 权限框,也不需要开 `chrome://inspect` 远程调试。
|
|
4
|
+
|
|
5
|
+
## 它做什么 / 不做什么
|
|
6
|
+
|
|
7
|
+
- ✅ 检索:你(或 agent)发起会话检索时,后台标签页打开携程机票列表页,被动嗅探页面自己发出的 `batchSearch` 响应,文本回传给本机的 gotry。收尾自动关闭自己开的标签。
|
|
8
|
+
- ✅ 登录检查:只读登录票据 cookie 的**名字**(cticket/uid/uname/passport),判断「是否已登录」这个布尔事实。
|
|
9
|
+
- ✅ 登录引导:把登录入口页**置前台**打开,登录由你在携程官网完成。
|
|
10
|
+
- ❌ **不读取、不存储、不回传任何 cookie 值,不接触密码/验证码**;
|
|
11
|
+
- ❌ 不向任何站点发起请求(检索请求由站点页面自己发出,扩展只听不写);
|
|
12
|
+
- ❌ 不动你已打开的任何标签页(只创建/关闭自己的检索标签;登录标签留给你)。
|
|
13
|
+
|
|
14
|
+
## 安装(每台浏览器一次,约 30 秒)
|
|
15
|
+
|
|
16
|
+
1. 跑一次 `npx gotry setup`(或手动:把本目录整体拷到任意固定位置,建议 `~/.gotry/extension/`);
|
|
17
|
+
2. Chrome 地址栏打开 `chrome://extensions`;
|
|
18
|
+
3. 右上角开启「开发者模式」;
|
|
19
|
+
4. 点「加载已解压的扩展程序」,选择本目录(manifest.json 所在目录)。
|
|
20
|
+
|
|
21
|
+
装好即生效,无任何系统弹窗。卸载/随时可停:扩展卡片上的开关即是总闸(仍受 gotry 授权闸 `sessionAccess` 双重控制)。
|
|
22
|
+
|
|
23
|
+
## 自检
|
|
24
|
+
|
|
25
|
+
- 扩展 ID 应为固定的 `olpgkofjhhiiiahdkkbcninhjmegghfe`(manifest 带 `key`,跨机器稳定)——若不一致,说明 manifest 被改动过,不要安装;
|
|
26
|
+
- gotry 侧运行 `npx gotry` 后发起一次会话检索,扩展图标应出现(无需点击);桥状态可看 `curl http://127.0.0.1:8791/status`(仅本机回环)。
|
|
@@ -0,0 +1,160 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* GoTry Session Bridge — MV3 Service Worker(RFC user-session-data-rfc.md §2.2 通道 C,2026-08-29 定案为 PRIMARY 传输)。
|
|
3
|
+
*
|
|
4
|
+
* 职责(与 Node 侧 session/extension-bridge.ts 配对,零构建纯 JS):
|
|
5
|
+
* - 长轮询取活:POST /jobs(桥最多 hold 20s;每次响应/失败都重置 SW 30s 生命周期,任务秒级触达);
|
|
6
|
+
* 断线每 5s 重试,chrome.alarms(30s)兜底唤醒防 SW 悬挂。
|
|
7
|
+
* - 三种 job:
|
|
8
|
+
* search → 后台标签打开 job.url(仅 flights.ctrip.com 白名单),等 content hook 嗅探回包,收尾关自己的标签;
|
|
9
|
+
* open-login → 置前台打开登录入口页(登录页纪律 #34),标签留给用户,绝不代关;
|
|
10
|
+
* cookie-names → chrome.cookies 只读票据 cookie **名字**(值即取即弃,永不离开扩展——红线)。
|
|
11
|
+
* - 物理只读(ReadGuard 扩展车道形态):本 SW 绝不向站点发任何请求——检索请求由站点自己的
|
|
12
|
+
* 页面代码发出,我们只「导航 + 被动转发 NETWORK_HINTS 命中响应」;写不是被禁止的行为,是不存在的原语。
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
'use strict'
|
|
16
|
+
|
|
17
|
+
const BRIDGE_PORTS = [8791, 8792, 8793, 8794, 8795]
|
|
18
|
+
const RETRY_MS = 5_000
|
|
19
|
+
|
|
20
|
+
/** 站点注册表(与 Node 侧 LOGIN_TARGETS/LOGIN_COOKIE_NAMES 对账,防漂移测试守住) */
|
|
21
|
+
const SITES = {
|
|
22
|
+
'ctrip-flight': {
|
|
23
|
+
domain: 'ctrip.com',
|
|
24
|
+
ticketNames: ['cticket', 'uid', 'uname', 'passport'],
|
|
25
|
+
},
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
let activePort = null
|
|
29
|
+
let polling = false
|
|
30
|
+
|
|
31
|
+
function sleep(ms) {
|
|
32
|
+
return new Promise((resolve) => setTimeout(resolve, ms))
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
async function detectPort() {
|
|
36
|
+
for (const p of BRIDGE_PORTS) {
|
|
37
|
+
try {
|
|
38
|
+
const r = await fetch(`http://127.0.0.1:${p}/health`, { headers: { 'x-gotry-bridge': 'v1' } })
|
|
39
|
+
if (r.ok) return p
|
|
40
|
+
} catch { /* 无人监听,试下一个端口 */ }
|
|
41
|
+
}
|
|
42
|
+
return null
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
async function postResult(jobId, result) {
|
|
46
|
+
if (activePort == null || !jobId) return
|
|
47
|
+
try {
|
|
48
|
+
await fetch(`http://127.0.0.1:${activePort}/results/${encodeURIComponent(jobId)}`, {
|
|
49
|
+
method: 'POST',
|
|
50
|
+
headers: { 'content-type': 'application/json', 'x-gotry-bridge': 'v1' },
|
|
51
|
+
body: JSON.stringify(result),
|
|
52
|
+
})
|
|
53
|
+
} catch { /* 桥可能已退场;Node 侧超时兜底 */ }
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/** 只读票据 cookie 名单(只上报名;chrome.cookies 的值就地丢弃,不进任何结果对象) */
|
|
57
|
+
async function ticketNames(site) {
|
|
58
|
+
const conf = SITES[site]
|
|
59
|
+
if (!conf) return []
|
|
60
|
+
const cookies = await chrome.cookies.getAll({ domain: conf.domain }).catch(() => [])
|
|
61
|
+
return (Array.isArray(cookies) ? cookies : [])
|
|
62
|
+
.map((c) => c.name)
|
|
63
|
+
.filter((n) => conf.ticketNames.includes(n))
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/** 后台标签等嗅探回包;超时带回页标题(供 Node 侧 CHALLENGE_RE 判定),收尾关自己的标签 */
|
|
67
|
+
function waitSniff(tabId, timeoutMs) {
|
|
68
|
+
return new Promise((resolve) => {
|
|
69
|
+
let settled = false
|
|
70
|
+
let title = ''
|
|
71
|
+
const handler = (msg, sender) => {
|
|
72
|
+
if (!sender || !sender.tab || sender.tab.id !== tabId) return
|
|
73
|
+
if (msg && msg.type === 'gotry-page') { title = String(msg.title ?? title); return }
|
|
74
|
+
if (msg && msg.type === 'gotry-sniff' && !settled) {
|
|
75
|
+
settled = true
|
|
76
|
+
clearTimeout(timer)
|
|
77
|
+
chrome.runtime.onMessage.removeListener(handler)
|
|
78
|
+
resolve({ ok: true, kind: 'search', body: String(msg.body ?? ''), title })
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
const timer = setTimeout(() => {
|
|
82
|
+
if (settled) return
|
|
83
|
+
settled = true
|
|
84
|
+
chrome.runtime.onMessage.removeListener(handler)
|
|
85
|
+
resolve({ ok: false, kind: 'search', timeout: true, title })
|
|
86
|
+
}, Math.max(Number(timeoutMs) || 30_000, 5_000))
|
|
87
|
+
chrome.runtime.onMessage.addListener(handler)
|
|
88
|
+
})
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
async function handleJob(job) {
|
|
92
|
+
const jobId = job && job.jobId
|
|
93
|
+
if (typeof jobId !== 'string' || !jobId) return
|
|
94
|
+
const site = String((job && job.site) || '')
|
|
95
|
+
const conf = SITES[site]
|
|
96
|
+
try {
|
|
97
|
+
if (!conf) {
|
|
98
|
+
await postResult(jobId, { ok: false, error: `unknown site ${site}` })
|
|
99
|
+
return
|
|
100
|
+
}
|
|
101
|
+
if (job.kind === 'cookie-names') {
|
|
102
|
+
await postResult(jobId, { ok: true, kind: 'cookie-names', names: await ticketNames(site) })
|
|
103
|
+
return
|
|
104
|
+
}
|
|
105
|
+
if (job.kind === 'open-login') {
|
|
106
|
+
if (typeof job.url !== 'string' || !job.url.startsWith('https://')) {
|
|
107
|
+
await postResult(jobId, { ok: false, error: 'open-login 需要 https job.url' })
|
|
108
|
+
return
|
|
109
|
+
}
|
|
110
|
+
const tab = await chrome.tabs.create({ url: job.url, active: true })
|
|
111
|
+
await postResult(jobId, { ok: true, kind: 'open-login', opened: true, tabId: tab.id })
|
|
112
|
+
return
|
|
113
|
+
}
|
|
114
|
+
if (job.kind === 'search') {
|
|
115
|
+
if (typeof job.url !== 'string' || !job.url.startsWith('https://flights.ctrip.com/')) {
|
|
116
|
+
await postResult(jobId, { ok: false, error: `search 只允许 flights.ctrip.com 域(收到 ${String(job.url).slice(0, 80)})` })
|
|
117
|
+
return
|
|
118
|
+
}
|
|
119
|
+
const tab = await chrome.tabs.create({ url: job.url, active: false })
|
|
120
|
+
const result = await waitSniff(tab.id, job.timeoutMs)
|
|
121
|
+
await chrome.tabs.remove(tab.id).catch(() => { /* 用户先关了,无妨 */ })
|
|
122
|
+
await postResult(jobId, result)
|
|
123
|
+
return
|
|
124
|
+
}
|
|
125
|
+
await postResult(jobId, { ok: false, error: `unknown job kind ${String(job.kind)}` })
|
|
126
|
+
} catch (e) {
|
|
127
|
+
await postResult(jobId, { ok: false, error: String(e && e.message ? e.message : e).slice(0, 200) })
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
async function loop() {
|
|
132
|
+
if (polling) return
|
|
133
|
+
polling = true
|
|
134
|
+
try {
|
|
135
|
+
for (;;) {
|
|
136
|
+
if (activePort == null) activePort = await detectPort()
|
|
137
|
+
if (activePort == null) { await sleep(RETRY_MS); continue }
|
|
138
|
+
try {
|
|
139
|
+
const r = await fetch(`http://127.0.0.1:${activePort}/jobs`, {
|
|
140
|
+
method: 'POST',
|
|
141
|
+
headers: { 'content-type': 'application/json', 'x-gotry-bridge': 'v1' },
|
|
142
|
+
body: JSON.stringify({
|
|
143
|
+
extensionVersion: chrome.runtime.getManifest().version,
|
|
144
|
+
capabilities: Object.keys(SITES),
|
|
145
|
+
}),
|
|
146
|
+
})
|
|
147
|
+
const data = await r.json().catch(() => ({ job: null }))
|
|
148
|
+
if (data && data.job) await handleJob(data.job)
|
|
149
|
+
} catch { activePort = null; await sleep(RETRY_MS) }
|
|
150
|
+
}
|
|
151
|
+
} finally {
|
|
152
|
+
polling = false
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
// SW 冷启动 + 闹钟兜底双保险;loop() 幂等
|
|
157
|
+
if (chrome.runtime.onStartup) chrome.runtime.onStartup.addListener(() => { void loop() })
|
|
158
|
+
chrome.alarms.create('gotry-bridge-poll', { periodInMinutes: 0.5 })
|
|
159
|
+
chrome.alarms.onAlarm.addListener((a) => { if (a.name === 'gotry-bridge-poll') void loop() })
|
|
160
|
+
void loop()
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* GoTry Session Bridge — ISOLATED-world 桥(manifest 默认 world,document_start)。
|
|
3
|
+
*
|
|
4
|
+
* MAIN world(content-main.js)拿不到 chrome API;本脚本监听页面里的
|
|
5
|
+
* gotry-ctrip-sniff 自定义事件,把响应文本经 chrome.runtime.sendMessage 转发给
|
|
6
|
+
* Service Worker(onMessage 按 sender.tab.id 归属到对应 search job)。
|
|
7
|
+
*
|
|
8
|
+
* 附带页标题快报(gotry-page):SW 在嗅探超时/挑战判定(CHALLENGE_RE)时用它。
|
|
9
|
+
*/
|
|
10
|
+
;(function () {
|
|
11
|
+
if (window.__gotrySniffBridge) return
|
|
12
|
+
window.__gotrySniffBridge = true
|
|
13
|
+
|
|
14
|
+
window.addEventListener('gotry-ctrip-sniff', function (ev) {
|
|
15
|
+
try {
|
|
16
|
+
var d = (ev && ev.detail) || {}
|
|
17
|
+
if (!d.body) return
|
|
18
|
+
chrome.runtime.sendMessage({
|
|
19
|
+
type: 'gotry-sniff',
|
|
20
|
+
url: d.url,
|
|
21
|
+
body: d.body,
|
|
22
|
+
title: document.title || '',
|
|
23
|
+
}).catch(function () { /* SW 重启中,下一条嗅探再投 */ })
|
|
24
|
+
} catch { /* 不抛 */ }
|
|
25
|
+
})
|
|
26
|
+
|
|
27
|
+
function sendPage() {
|
|
28
|
+
try {
|
|
29
|
+
chrome.runtime.sendMessage({
|
|
30
|
+
type: 'gotry-page',
|
|
31
|
+
title: document.title || '',
|
|
32
|
+
url: location.href,
|
|
33
|
+
}).catch(function () { /* SW 侧无等待者,常态丢弃 */ })
|
|
34
|
+
} catch { /* 环境异常不抛 */ }
|
|
35
|
+
}
|
|
36
|
+
if (document.readyState === 'loading') {
|
|
37
|
+
window.addEventListener('DOMContentLoaded', sendPage, { once: true })
|
|
38
|
+
} else {
|
|
39
|
+
sendPage()
|
|
40
|
+
}
|
|
41
|
+
})()
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* GoTry Session Bridge — MAIN-world 网络嗅探(manifest world:"MAIN",document_start)。
|
|
3
|
+
*
|
|
4
|
+
* MAIN world 没有 chrome.runtime,只负责:hook 站点自己的 fetch/XHR,把命中
|
|
5
|
+
* NETWORK_HINTS(batchSearch)的响应文本以 CustomEvent 派发到页面里;
|
|
6
|
+
* ISOLATED 侧的 content-bridge.js 监听事件并经 chrome.runtime 转发给 Service Worker。
|
|
7
|
+
*
|
|
8
|
+
* 只读被动转发:不修改请求、不重放、不伪造(§2.3 反模式红线)——响应由站点自己的
|
|
9
|
+
* 代码发出,我们只听。
|
|
10
|
+
*/
|
|
11
|
+
;(function () {
|
|
12
|
+
if (window.__gotrySniffInstalled) return
|
|
13
|
+
window.__gotrySniffInstalled = true
|
|
14
|
+
|
|
15
|
+
/** 与 Node 侧 adapters/ctrip-flight.ts NETWORK_HINTS 对账(run-all §38 防漂移断言) */
|
|
16
|
+
var HINT_RE = /search\/api\/search\/batchSearch/
|
|
17
|
+
|
|
18
|
+
function dispatch(url, body) {
|
|
19
|
+
try {
|
|
20
|
+
window.dispatchEvent(new CustomEvent('gotry-ctrip-sniff', { detail: { url: String(url), body: String(body) } }))
|
|
21
|
+
} catch { /* 页面环境异常不抛 */ }
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
var origFetch = window.fetch
|
|
25
|
+
if (typeof origFetch === 'function') {
|
|
26
|
+
window.fetch = function (input, init) {
|
|
27
|
+
return origFetch.apply(this, arguments).then(function (res) {
|
|
28
|
+
try {
|
|
29
|
+
var url = ''
|
|
30
|
+
if (typeof input === 'string') url = input
|
|
31
|
+
else if (input && typeof input.url === 'string') url = input.url
|
|
32
|
+
if (HINT_RE.test(url)) {
|
|
33
|
+
var clone = res.clone()
|
|
34
|
+
clone.text().then(function (t) { dispatch(url, t) }).catch(function () { /* 流不可读则跳过 */ })
|
|
35
|
+
}
|
|
36
|
+
} catch { /* 嗅探失败不影响站点自身 */ }
|
|
37
|
+
return res
|
|
38
|
+
})
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
var origOpen = XMLHttpRequest.prototype.open
|
|
43
|
+
var origSend = XMLHttpRequest.prototype.send
|
|
44
|
+
XMLHttpRequest.prototype.open = function (method, url) {
|
|
45
|
+
this.__gotryUrl = typeof url === 'string' ? url : String(url ?? '')
|
|
46
|
+
return origOpen.apply(this, arguments)
|
|
47
|
+
}
|
|
48
|
+
XMLHttpRequest.prototype.send = function () {
|
|
49
|
+
var xhr = this
|
|
50
|
+
xhr.addEventListener('load', function () {
|
|
51
|
+
try {
|
|
52
|
+
if (!xhr.__gotryUrl || !HINT_RE.test(xhr.__gotryUrl)) return
|
|
53
|
+
var body = ''
|
|
54
|
+
if (xhr.responseType === '' || xhr.responseType === 'text') body = xhr.responseText
|
|
55
|
+
else if (xhr.responseType === 'json') body = JSON.stringify(xhr.response)
|
|
56
|
+
else return
|
|
57
|
+
if (body) dispatch(xhr.__gotryUrl, body)
|
|
58
|
+
} catch { /* 嗅探失败不影响站点 */ }
|
|
59
|
+
})
|
|
60
|
+
return origSend.apply(this, arguments)
|
|
61
|
+
}
|
|
62
|
+
})()
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
{
|
|
2
|
+
"manifest_version": 3,
|
|
3
|
+
"name": "GoTry Session Bridge",
|
|
4
|
+
"version": "0.1.0",
|
|
5
|
+
"description": "GoTry 会话检索桥(issue #21):在你自己的登录态里只读嗅探检索回包与登录票据 cookie 名;零凭证经手,登录在官网由你完成,检索只读不写。gotry 侧不再需要开启 Remote Debugging,也没有任何系统级权限弹窗。",
|
|
6
|
+
"key": "MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAnuzBX8rT8+RZKEUip5VRA0cY056pExy9PNdN9YCiuY3dgglRbcIBhjuFJoR6EX3/MaUv82vJVyItRVrowXLTwDk4WZof0vwFgKL0zBK304PC46pUaAKTSu1PgXeL1+j5KTAGz/9a9+RnPdbj7jND5rM/MOmBHNPVPpp1NlwxRGIgF9OxHSOfXORcH4Iyte1aJSFUJbFtNS/DaE5D+KjyupPLyIqVN/dCiKnNsTCEHQB9ngeli665PFp76hkccHQQZ4sVuMLS4zBPcazUsp1kcnW+xsQoUATkODTCY+Pzk3zUVF1QOESTjRCVnbNi8tkO43RMRs2q63gX/3inFivcEwIDAQAB",
|
|
7
|
+
"permissions": ["cookies", "alarms"],
|
|
8
|
+
"host_permissions": [
|
|
9
|
+
"http://127.0.0.1:8791/*",
|
|
10
|
+
"http://127.0.0.1:8792/*",
|
|
11
|
+
"http://127.0.0.1:8793/*",
|
|
12
|
+
"http://127.0.0.1:8794/*",
|
|
13
|
+
"http://127.0.0.1:8795/*",
|
|
14
|
+
"https://*.ctrip.com/*"
|
|
15
|
+
],
|
|
16
|
+
"background": {
|
|
17
|
+
"service_worker": "background.js"
|
|
18
|
+
},
|
|
19
|
+
"content_scripts": [
|
|
20
|
+
{
|
|
21
|
+
"matches": ["https://flights.ctrip.com/*"],
|
|
22
|
+
"js": ["content-main.js"],
|
|
23
|
+
"run_at": "document_start",
|
|
24
|
+
"world": "MAIN"
|
|
25
|
+
},
|
|
26
|
+
{
|
|
27
|
+
"matches": ["https://flights.ctrip.com/*"],
|
|
28
|
+
"js": ["content-bridge.js"],
|
|
29
|
+
"run_at": "document_start"
|
|
30
|
+
}
|
|
31
|
+
]
|
|
32
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@danceiny/gotry",
|
|
3
|
-
"version": "0.0.1-rc.
|
|
3
|
+
"version": "0.0.1-rc.16",
|
|
4
4
|
"description": "GoTry — 从出发到下一次出发的 AI 旅行 Agent(dsh 插件)。npm 包入口 + vendored dsh runtime + 5 行 README 安装路径。",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "ts/src/index.ts",
|
|
@@ -20,6 +20,9 @@
|
|
|
20
20
|
"ts/src/dsh-llm.ts",
|
|
21
21
|
"ts/src/mock-llm.ts",
|
|
22
22
|
"ts/src/state-ledger.ts",
|
|
23
|
+
"ts/src/booking-saga.ts",
|
|
24
|
+
"ts/src/bookable-facts.ts",
|
|
25
|
+
"ts/src/artifact-gate.ts",
|
|
23
26
|
"ts/src/tool-packet.ts",
|
|
24
27
|
"ts/src/memory-capture.ts",
|
|
25
28
|
"ts/src/memory-utility.ts",
|
|
@@ -44,13 +47,16 @@
|
|
|
44
47
|
"ts/capabilities/flyai.ts",
|
|
45
48
|
"ts/capabilities/session-search.ts",
|
|
46
49
|
"ts/capabilities/session/",
|
|
50
|
+
"extension/",
|
|
47
51
|
"ts/package.json",
|
|
48
52
|
"cordis.gotry-patch.yml",
|
|
49
53
|
"README.md",
|
|
50
54
|
"README.zh-CN.md",
|
|
51
55
|
"LICENSE",
|
|
52
56
|
"ts/capabilities/session-consent.ts",
|
|
53
|
-
"ts/capabilities/session-login.ts"
|
|
57
|
+
"ts/capabilities/session-login.ts",
|
|
58
|
+
"ts/capabilities/artifacts.ts",
|
|
59
|
+
"ts/capabilities/fact-log.ts"
|
|
54
60
|
],
|
|
55
61
|
"engines": {
|
|
56
62
|
"node": ">=22.0.0"
|
|
@@ -0,0 +1,235 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 产物面(issue #25):agent 生成的文件不再只是「落在本地文件系统里的一个文件名」。
|
|
3
|
+
*
|
|
4
|
+
* 只读能力层,两个纯函数入口(dsh 工具 gotry_artifacts_list / gotry_artifacts_read 的实现):
|
|
5
|
+
* - listArtifacts: 产物发现。权威源 = 账本 workflow_runs(ADR-15;无账本的旧 root 回退
|
|
6
|
+
* 扫描 gotry-state/async/*.deliverable.md 文件视图),外加 dsh 工作目录顶层 *.md
|
|
7
|
+
* (agent 写出的行程/规划文件正落在这里——issue 截图里的 trip-2027-*.md 即此类)。
|
|
8
|
+
* - readArtifact: 产物阅读。行窗口(offset/limit)+ 原始行号,输出 dsh read 卡所需的
|
|
9
|
+
* 全部字段({number,text}[] / totalLines / lang),UI 侧渲染为行号文件视图。
|
|
10
|
+
*
|
|
11
|
+
* 纪律:本层只读(不写任何文件;WriteGate 红线不涉及);读取范围白名单 =
|
|
12
|
+
* stateRoot 根 + dsh 工作目录(排除 node_modules/.git),扩展名白名单 =
|
|
13
|
+
* 文本类(md/txt/json/jsonl/csv/log/yaml/yml)——本工具是「产物查看」,不是通用文件浏览器。
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
import { readdir, readFile, stat } from 'node:fs/promises'
|
|
17
|
+
import { existsSync } from 'node:fs'
|
|
18
|
+
import { isAbsolute, join, resolve, sep } from 'node:path'
|
|
19
|
+
|
|
20
|
+
import { openLedgerIfExists } from '../src/state-ledger.ts'
|
|
21
|
+
|
|
22
|
+
export interface ArtifactEntry {
|
|
23
|
+
source: 'async-run' | 'cwd-file'
|
|
24
|
+
id: string
|
|
25
|
+
title: string
|
|
26
|
+
path: string
|
|
27
|
+
status?: string
|
|
28
|
+
updated?: string
|
|
29
|
+
bytes?: number
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export interface ArtifactReadView {
|
|
33
|
+
ok: true
|
|
34
|
+
path: string
|
|
35
|
+
offset: number
|
|
36
|
+
lines: Array<{ number: number; text: string }>
|
|
37
|
+
totalLines: number
|
|
38
|
+
lang?: string
|
|
39
|
+
content: string
|
|
40
|
+
windowed: boolean
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
const MAX_LIST = 50
|
|
44
|
+
const MAX_WINDOW = 400
|
|
45
|
+
const MAX_BYTES = 2 * 1024 * 1024
|
|
46
|
+
const TEXT_EXT_LANG: Record<string, string> = {
|
|
47
|
+
md: 'markdown', txt: 'text', json: 'json', jsonl: 'json', csv: 'csv', log: 'text', yaml: 'yaml', yml: 'yaml',
|
|
48
|
+
}
|
|
49
|
+
const DIR_DENY = ['node_modules', '.git']
|
|
50
|
+
|
|
51
|
+
function rootOf(stateRoot: string): string {
|
|
52
|
+
return stateRoot === '.' ? process.cwd() : resolve(stateRoot)
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
function underRoot(p: string, root: string): boolean {
|
|
56
|
+
const r = resolve(root)
|
|
57
|
+
return p === r || p.startsWith(r + sep)
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
function hasDeniedSegment(p: string): boolean {
|
|
61
|
+
return p.split(sep).some(seg => DIR_DENY.includes(seg))
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
function asyncDeliverablePath(root: string, id: string): string {
|
|
65
|
+
return join(root, 'gotry-state', 'async', `${id}.deliverable.md`)
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/** 账本 workflow_runs 是权威(listWorkflowRuns 只读 SELECT 直查,不为一个视图改 ledger 类)。 */
|
|
69
|
+
function listRunsFromLedger(root: string, tenant: string, limit: number): ArtifactEntry[] {
|
|
70
|
+
const ledger = openLedgerIfExists(root, tenant)
|
|
71
|
+
if (!ledger) return []
|
|
72
|
+
const rows = ledger.db
|
|
73
|
+
.prepare('SELECT id, goal, status, deliverable, updated FROM workflow_runs WHERE tenant_id = ? ORDER BY updated DESC LIMIT ?')
|
|
74
|
+
.all(ledger.tenant, limit) as Array<{ id: string; goal: string; status: string; deliverable: string | null; updated: string }>
|
|
75
|
+
return rows.map(r => {
|
|
76
|
+
const file = asyncDeliverablePath(root, r.id)
|
|
77
|
+
return {
|
|
78
|
+
source: 'async-run' as const,
|
|
79
|
+
id: r.id,
|
|
80
|
+
title: r.goal,
|
|
81
|
+
path: file,
|
|
82
|
+
status: r.status,
|
|
83
|
+
updated: r.updated,
|
|
84
|
+
bytes: r.deliverable?.length,
|
|
85
|
+
}
|
|
86
|
+
})
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/** 无账本旧 root 的兼容视图:直接扫 async 目录的 deliverable 文件(只读,与清扫合同同一目录)。 */
|
|
90
|
+
async function listDeliverableFiles(root: string, limit: number): Promise<ArtifactEntry[]> {
|
|
91
|
+
const dir = join(root, 'gotry-state', 'async')
|
|
92
|
+
let names: string[] = []
|
|
93
|
+
try {
|
|
94
|
+
names = (await readdir(dir)).filter(n => n.endsWith('.deliverable.md'))
|
|
95
|
+
} catch {
|
|
96
|
+
return []
|
|
97
|
+
}
|
|
98
|
+
const entries: ArtifactEntry[] = []
|
|
99
|
+
for (const n of names.slice(0, limit * 2)) {
|
|
100
|
+
const p = join(dir, n)
|
|
101
|
+
const st = await stat(p).catch(() => null)
|
|
102
|
+
if (!st?.isFile()) continue
|
|
103
|
+
entries.push({
|
|
104
|
+
source: 'async-run',
|
|
105
|
+
id: n.replace(/\.deliverable\.md$/, ''),
|
|
106
|
+
title: n.replace(/\.deliverable\.md$/, ''),
|
|
107
|
+
path: p,
|
|
108
|
+
status: existsSync(p.replace(/\.deliverable\.md$/, '.json')) ? 'pending-view' : 'legacy',
|
|
109
|
+
updated: new Date(st.mtimeMs).toISOString(),
|
|
110
|
+
bytes: st.size,
|
|
111
|
+
})
|
|
112
|
+
}
|
|
113
|
+
return entries.sort((a, b) => String(b.updated).localeCompare(String(a.updated)))
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
/** dsh 工作目录顶层 *.md(agent 写出的行程规划等);非递归,排除 dotfiles。 */
|
|
117
|
+
async function listCwdMarkdown(cwd: string, limit: number): Promise<ArtifactEntry[]> {
|
|
118
|
+
let dirents
|
|
119
|
+
try {
|
|
120
|
+
dirents = await readdir(cwd, { withFileTypes: true })
|
|
121
|
+
} catch {
|
|
122
|
+
return []
|
|
123
|
+
}
|
|
124
|
+
const entries: ArtifactEntry[] = []
|
|
125
|
+
for (const d of dirents) {
|
|
126
|
+
if (!d.isFile() || !/\.md$/i.test(d.name) || d.name.startsWith('.')) continue
|
|
127
|
+
const p = join(cwd, d.name)
|
|
128
|
+
const st = await stat(p).catch(() => null)
|
|
129
|
+
if (!st) continue
|
|
130
|
+
entries.push({
|
|
131
|
+
source: 'cwd-file',
|
|
132
|
+
id: d.name,
|
|
133
|
+
title: d.name.replace(/\.md$/i, ''),
|
|
134
|
+
path: p,
|
|
135
|
+
updated: new Date(st.mtimeMs).toISOString(),
|
|
136
|
+
bytes: st.size,
|
|
137
|
+
})
|
|
138
|
+
}
|
|
139
|
+
return entries.sort((a, b) => String(b.updated).localeCompare(String(a.updated))).slice(0, limit)
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
export async function listArtifacts(opts: {
|
|
143
|
+
stateRoot: string
|
|
144
|
+
cwd?: string
|
|
145
|
+
limit?: number
|
|
146
|
+
}): Promise<{ artifacts: ArtifactEntry[]; total: number; truncated: boolean; roots: string[] }> {
|
|
147
|
+
const limit = Math.max(1, Math.min(opts.limit ?? 20, MAX_LIST))
|
|
148
|
+
const root = rootOf(opts.stateRoot)
|
|
149
|
+
const cwd = opts.cwd ? resolve(opts.cwd) : process.cwd()
|
|
150
|
+
|
|
151
|
+
const seenPath = new Set<string>()
|
|
152
|
+
const merged: ArtifactEntry[] = []
|
|
153
|
+
for (const e of [...listRunsFromLedger(root, 'local', limit), ...(await listDeliverableFiles(root, limit)), ...(await listCwdMarkdown(cwd, limit))]) {
|
|
154
|
+
if (seenPath.has(e.path)) continue
|
|
155
|
+
seenPath.add(e.path)
|
|
156
|
+
merged.push(e)
|
|
157
|
+
}
|
|
158
|
+
merged.sort((a, b) => String(b.updated ?? '').localeCompare(String(a.updated ?? '')))
|
|
159
|
+
|
|
160
|
+
const total = merged.length
|
|
161
|
+
return { artifacts: merged.slice(0, limit), total, truncated: total > limit, roots: [root, cwd] }
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
/**
|
|
165
|
+
* 读一个产物。path 三形态:
|
|
166
|
+
* 1. 裸工单 id(无 / 无 .)→ 账本 workflow_runs.deliverable(权威),文件视图缺失也能读;
|
|
167
|
+
* 2. list 返回的绝对/相对路径 → 限定在 stateRoot 根或 dsh 工作目录内(排除 node_modules/.git);
|
|
168
|
+
* 3. 相对文件名 → 先按 dsh 工作目录顶层,再按 gotry-state/async/ 找。
|
|
169
|
+
* 窗口:offset(1 起)/limit(≤400 行);超窗返回 windowed:true,UI 用 read 卡渲染行号视图。
|
|
170
|
+
*/
|
|
171
|
+
export async function readArtifact(opts: {
|
|
172
|
+
stateRoot: string
|
|
173
|
+
cwd?: string
|
|
174
|
+
path: string
|
|
175
|
+
offset?: number
|
|
176
|
+
limit?: number
|
|
177
|
+
}): Promise<ArtifactReadView | { ok: false; error: string; hint?: string }> {
|
|
178
|
+
const root = rootOf(opts.stateRoot)
|
|
179
|
+
const cwd = opts.cwd ? resolve(opts.cwd) : process.cwd()
|
|
180
|
+
const raw = String(opts.path ?? '').trim()
|
|
181
|
+
if (!raw) return { ok: false, error: 'path 必填(来自 gotry_artifacts_list 的 path,或异步工单 id)' }
|
|
182
|
+
|
|
183
|
+
let text: string | null = null
|
|
184
|
+
let filePath = ''
|
|
185
|
+
|
|
186
|
+
// 1) 裸工单 id:账本权威读(文件视图缺失不挡阅读)
|
|
187
|
+
if (!raw.includes('/') && !raw.includes('\\') && !raw.includes('.')) {
|
|
188
|
+
const ledger = openLedgerIfExists(root, 'local')
|
|
189
|
+
const run = ledger?.db
|
|
190
|
+
.prepare('SELECT id, goal, status, deliverable FROM workflow_runs WHERE id = ? AND tenant_id = ?')
|
|
191
|
+
.get(raw, ledger!.tenant) as { deliverable: string | null } | undefined
|
|
192
|
+
if (run?.deliverable) {
|
|
193
|
+
text = run.deliverable
|
|
194
|
+
filePath = asyncDeliverablePath(root, raw)
|
|
195
|
+
} else {
|
|
196
|
+
const p = asyncDeliverablePath(root, raw)
|
|
197
|
+
if (existsSync(p)) { filePath = p; text = await readFile(p, 'utf-8') }
|
|
198
|
+
}
|
|
199
|
+
if (text === null) return { ok: false, error: `工单 ${raw} 无 deliverable(未交付或不存在)`, hint: '先 gotry_artifacts_list 看在册产物' }
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
// 2) 路径形态:目录白名单 + 扩展名白名单
|
|
203
|
+
if (text === null) {
|
|
204
|
+
const candidates = isAbsolute(raw) ? [resolve(raw)] : [resolve(cwd, raw), resolve(root, raw), resolve(root, 'gotry-state', 'async', raw)]
|
|
205
|
+
const allowed = candidates.find(p => (underRoot(p, cwd) || underRoot(p, root)) && !hasDeniedSegment(p))
|
|
206
|
+
if (!allowed) {
|
|
207
|
+
return { ok: false, error: `路径越界:${raw}`, hint: `只读 ${root} 与 dsh 工作目录内的文本产物` }
|
|
208
|
+
}
|
|
209
|
+
const ext = allowed.slice(allowed.lastIndexOf('.') + 1).toLowerCase()
|
|
210
|
+
if (!TEXT_EXT_LANG[ext]) {
|
|
211
|
+
return { ok: false, error: `不支持的文件类型 .${ext}`, hint: `白名单:${Object.keys(TEXT_EXT_LANG).join('/')}` }
|
|
212
|
+
}
|
|
213
|
+
const st = await stat(allowed).catch(() => null)
|
|
214
|
+
if (!st?.isFile()) return { ok: false, error: `文件不存在:${raw}`, hint: '先 gotry_artifacts_list 看在册产物' }
|
|
215
|
+
if (st.size > MAX_BYTES) return { ok: false, error: `文件过大(${st.size} bytes > ${MAX_BYTES})` }
|
|
216
|
+
filePath = allowed
|
|
217
|
+
text = await readFile(allowed, 'utf-8')
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
const allLines = text.split('\n')
|
|
221
|
+
const offset = Math.max(1, Math.min(opts.offset ?? 1, allLines.length))
|
|
222
|
+
const limit = Math.max(1, Math.min(opts.limit ?? MAX_WINDOW, MAX_WINDOW))
|
|
223
|
+
const slice = allLines.slice(offset - 1, offset - 1 + limit)
|
|
224
|
+
const ext = filePath.slice(filePath.lastIndexOf('.') + 1).toLowerCase()
|
|
225
|
+
return {
|
|
226
|
+
ok: true,
|
|
227
|
+
path: filePath,
|
|
228
|
+
offset,
|
|
229
|
+
lines: slice.map((t, i) => ({ number: offset + i, text: t })),
|
|
230
|
+
totalLines: allLines.length,
|
|
231
|
+
lang: TEXT_EXT_LANG[ext] ?? 'text',
|
|
232
|
+
content: slice.join('\n'),
|
|
233
|
+
windowed: allLines.length > offset - 1 + slice.length,
|
|
234
|
+
}
|
|
235
|
+
}
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 可下单事实侧车持久化(issue #46,ADR-19):append-only JSONL 证据日志。
|
|
3
|
+
*
|
|
4
|
+
* 定位:这是**会话证据**而非用户状态——与 bridge-latency.jsonl / incidents.jsonl
|
|
5
|
+
* 同类(审计痕迹),不进账本投影。append-only 保历史(同 route+date 重查的旧快照
|
|
6
|
+
* 可回溯),读取侧经 dedupeFacts 取同 fact_id 最新。
|
|
7
|
+
*
|
|
8
|
+
* 路径:<stateRoot>/gotry-state/bookable-facts.jsonl
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import { appendFile, mkdir, readFile } from 'node:fs/promises'
|
|
12
|
+
import { join } from 'node:path'
|
|
13
|
+
import { dedupeFacts, type BookableFact } from '../src/bookable-facts.ts'
|
|
14
|
+
|
|
15
|
+
export const FACT_LOG_NAME = 'bookable-facts.jsonl'
|
|
16
|
+
|
|
17
|
+
export function factLogPath(stateRoot: string): string {
|
|
18
|
+
const root = stateRoot === '.' ? process.cwd() : stateRoot
|
|
19
|
+
return join(root, 'gotry-state', FACT_LOG_NAME)
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/** 追加事实(永不抛错:落盘失败不阻塞检索工具主路径,返回 false 由调用方记 incident) */
|
|
23
|
+
export async function appendFacts(stateRoot: string, facts: BookableFact[]): Promise<boolean> {
|
|
24
|
+
if (facts.length === 0) return true
|
|
25
|
+
try {
|
|
26
|
+
const p = factLogPath(stateRoot)
|
|
27
|
+
await mkdir(join(p, '..'), { recursive: true })
|
|
28
|
+
await appendFile(p, facts.map(f => JSON.stringify(f)).join('\n') + '\n', 'utf-8')
|
|
29
|
+
return true
|
|
30
|
+
} catch {
|
|
31
|
+
return false
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/** 读注册表(去重后视图;无文件 = 空注册表,不抛) */
|
|
36
|
+
export async function loadFactRegistry(stateRoot: string): Promise<BookableFact[]> {
|
|
37
|
+
try {
|
|
38
|
+
const raw = await readFile(factLogPath(stateRoot), 'utf-8')
|
|
39
|
+
const facts: BookableFact[] = []
|
|
40
|
+
for (const line of raw.split('\n')) {
|
|
41
|
+
const t = line.trim()
|
|
42
|
+
if (!t) continue
|
|
43
|
+
try {
|
|
44
|
+
const f = JSON.parse(t) as BookableFact
|
|
45
|
+
if (f.schema === 'gotry_bookable_fact.v1' && f.fact_id) facts.push(f)
|
|
46
|
+
} catch {
|
|
47
|
+
// 单行损坏不拖垮整本注册表(与 incidents/latency 日志同纪律)
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
return dedupeFacts(facts)
|
|
51
|
+
} catch {
|
|
52
|
+
return []
|
|
53
|
+
}
|
|
54
|
+
}
|