@danceiny/gotry 0.0.1-rc.13 → 0.0.1-rc.15
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 +121 -140
- package/README.zh-CN.md +235 -0
- package/bin/gotry-bootstrap.js +165 -0
- package/bin/gotry-inner.js +10 -3
- package/dist/capabilities/artifacts.js +217 -0
- package/dist/capabilities/hbcli.js +42 -15
- package/dist/scripts/booking-saga-tests.js +187 -0
- package/dist/scripts/bootstrap-tests.js +66 -0
- package/dist/scripts/flyai-tests.js +95 -0
- package/dist/scripts/hbcli-tests.js +28 -3
- package/dist/scripts/nightly-evidence-tests.js +123 -0
- package/dist/scripts/nightly-evidence.js +233 -0
- package/dist/scripts/smoke.js +83 -0
- package/dist/src/booking-saga.js +153 -0
- package/dist/src/dsh-llm.js +30 -6
- package/dist/src/index.js +141 -3
- package/package.json +5 -2
- package/ts/capabilities/artifacts.ts +235 -0
- package/ts/capabilities/hbcli.ts +51 -19
- package/ts/src/booking-saga.ts +133 -0
- package/ts/src/dsh-llm.ts +40 -3
- package/ts/src/index.ts +145 -25
package/dist/src/index.js
CHANGED
|
@@ -23,6 +23,7 @@ import { flyaiSearch } from '../capabilities/flyai.js';
|
|
|
23
23
|
import { sessionFlightSearch } from '../capabilities/session-search.js';
|
|
24
24
|
import { sessionLogin } from '../capabilities/session-login.js';
|
|
25
25
|
import { createConsentGate, approvalFromContext } from '../capabilities/session-consent.js';
|
|
26
|
+
import { listArtifacts, readArtifact } from '../capabilities/artifacts.js';
|
|
26
27
|
export const name = 'gotry-tools';
|
|
27
28
|
export const inject = [
|
|
28
29
|
'tools',
|
|
@@ -585,10 +586,14 @@ export function apply(ctx, config) {
|
|
|
585
586
|
}),
|
|
586
587
|
presentResult: (_args, value)=>{
|
|
587
588
|
const r = value;
|
|
588
|
-
const
|
|
589
|
+
const h = r.hotels;
|
|
590
|
+
const liveCount = Array.isArray(h) ? h.length : 0;
|
|
591
|
+
const stays = !Array.isArray(h) && h && typeof h === 'object' ? h.stays : undefined;
|
|
592
|
+
const staticCount = Array.isArray(stays) ? stays.length : 0;
|
|
593
|
+
const tag = r.via === 'hbcli-realtime' ? liveCount ? `实时 ${liveCount} 家` : '实时' : staticCount ? `静态包 ${staticCount} 块` : liveCount ? `${liveCount} 家` : '无结果';
|
|
589
594
|
return {
|
|
590
595
|
card: 'generic',
|
|
591
|
-
title: `酒店:${r.destination ?? ''} ${
|
|
596
|
+
title: `酒店:${r.destination ?? ''} ${tag}`,
|
|
592
597
|
content: [
|
|
593
598
|
{
|
|
594
599
|
type: 'text',
|
|
@@ -875,7 +880,8 @@ export function apply(ctx, config) {
|
|
|
875
880
|
depDate: q.date
|
|
876
881
|
});
|
|
877
882
|
const top = (r.options ?? []).slice(0, 8).map((o)=>`${o.no} ${o.name} ${o.depDateTime.slice(11, 16)}→${o.arrDateTime.slice(11, 16)} ¥${o.price}`);
|
|
878
|
-
const
|
|
883
|
+
const label = kind === 'flight' ? '机票' : '火车票';
|
|
884
|
+
const summary = r.verdict === 'hit' ? `${q.from}→${q.to} ${q.date} ${label}(飞猪官方只读)前 ${top.length} 条:\n${top.join('\n')}\n${r.evidence}` : r.verdict === 'miss' ? `${q.from}→${q.to} ${q.date} ${label}官方通道正常返回 0 条(常见原因:航线未开放/当日售罄)。${r.evidence}` : `${q.from}→${q.to} ${q.date} ${label}检索失败(可能限流/网络):${r.error ?? ''} ${r.evidence}`;
|
|
879
885
|
return JSON.parse(JSON.stringify({
|
|
880
886
|
...r,
|
|
881
887
|
kind,
|
|
@@ -1352,6 +1358,138 @@ export function apply(ctx, config) {
|
|
|
1352
1358
|
};
|
|
1353
1359
|
}
|
|
1354
1360
|
}));
|
|
1361
|
+
registerGuarded(defineTool({
|
|
1362
|
+
name: 'gotry_artifacts_list',
|
|
1363
|
+
description: 'List GoTry artifacts — deep-planning deliverables (async runs from the state ledger) plus agent-written markdown files ' + 'in the working directory (trip plans etc.). READ-ONLY discovery. ' + 'Use when the user asks to see/open/revisit a previously generated artifact ' + '(「看看刚才生成的行程」「上次的规划在哪」「打开那个 md」) — list first, then read with gotry_artifacts_read.',
|
|
1364
|
+
parameters: {
|
|
1365
|
+
query: {
|
|
1366
|
+
type: 'json',
|
|
1367
|
+
required: true,
|
|
1368
|
+
description: '{ limit?: 20 }'
|
|
1369
|
+
}
|
|
1370
|
+
},
|
|
1371
|
+
output: {
|
|
1372
|
+
schema: {
|
|
1373
|
+
type: 'json'
|
|
1374
|
+
},
|
|
1375
|
+
render: (_args, value)=>[
|
|
1376
|
+
{
|
|
1377
|
+
type: 'text',
|
|
1378
|
+
text: String(value.summary ?? JSON.stringify(value).slice(0, 600))
|
|
1379
|
+
}
|
|
1380
|
+
]
|
|
1381
|
+
},
|
|
1382
|
+
async execute (args, _exec) {
|
|
1383
|
+
const q = unwrapQuery(args, 'limit');
|
|
1384
|
+
const r = await listArtifacts({
|
|
1385
|
+
stateRoot: config.stateRoot ?? '.',
|
|
1386
|
+
limit: q.limit
|
|
1387
|
+
});
|
|
1388
|
+
const lines = r.artifacts.map((a)=>`- [${a.source}] ${a.title}${a.status ? `(${a.status})` : ''} — ${a.path}${a.updated ? ` @ ${a.updated.slice(0, 16).replace('T', ' ')}` : ''}`);
|
|
1389
|
+
const summary = r.artifacts.length ? `在册产物 ${r.artifacts.length}/${r.total} 项${r.truncated ? '(截断,可加 limit)' : ''}:\n${lines.join('\n')}` : '无在册产物(异步深度规划交付与工作目录 md 文件都会出现在这里)';
|
|
1390
|
+
return JSON.parse(JSON.stringify({
|
|
1391
|
+
ok: true,
|
|
1392
|
+
artifacts: r.artifacts,
|
|
1393
|
+
total: r.total,
|
|
1394
|
+
truncated: r.truncated,
|
|
1395
|
+
summary
|
|
1396
|
+
}));
|
|
1397
|
+
},
|
|
1398
|
+
presentCall: ()=>({
|
|
1399
|
+
card: 'generic',
|
|
1400
|
+
title: '列出产物',
|
|
1401
|
+
kind: 'search'
|
|
1402
|
+
}),
|
|
1403
|
+
presentResult: (_args, value)=>{
|
|
1404
|
+
const r = value;
|
|
1405
|
+
return {
|
|
1406
|
+
card: 'generic',
|
|
1407
|
+
title: `产物:${r.total ?? 0} 项在册`,
|
|
1408
|
+
content: [
|
|
1409
|
+
{
|
|
1410
|
+
type: 'text',
|
|
1411
|
+
text: String(r.summary ?? '')
|
|
1412
|
+
}
|
|
1413
|
+
]
|
|
1414
|
+
};
|
|
1415
|
+
}
|
|
1416
|
+
}));
|
|
1417
|
+
registerGuarded(defineTool({
|
|
1418
|
+
name: 'gotry_artifacts_read',
|
|
1419
|
+
description: 'Read one GoTry artifact as a line-numbered file view rendered directly in the chat UI. ' + 'Input: the path from gotry_artifacts_list, or a bare async ticket id (e.g. dp-xxxx). ' + 'Optional offset (1-based) / limit window for paging large files. ' + 'READ-ONLY; text artifacts only (md/txt/json/jsonl/csv/log/yaml).',
|
|
1420
|
+
parameters: {
|
|
1421
|
+
query: {
|
|
1422
|
+
type: 'json',
|
|
1423
|
+
required: true,
|
|
1424
|
+
description: '{ path: "<list 返回的路径或工单 id>", offset?: 1, limit?: 400 }'
|
|
1425
|
+
}
|
|
1426
|
+
},
|
|
1427
|
+
output: {
|
|
1428
|
+
schema: {
|
|
1429
|
+
type: 'json'
|
|
1430
|
+
},
|
|
1431
|
+
render: (_args, value)=>[
|
|
1432
|
+
{
|
|
1433
|
+
type: 'text',
|
|
1434
|
+
text: String(value.content ?? JSON.stringify(value).slice(0, 600))
|
|
1435
|
+
}
|
|
1436
|
+
]
|
|
1437
|
+
},
|
|
1438
|
+
async execute (args, _exec) {
|
|
1439
|
+
const q = unwrapQuery(args, 'path');
|
|
1440
|
+
if (!q.path) return JSON.parse(JSON.stringify({
|
|
1441
|
+
ok: false,
|
|
1442
|
+
error: 'path 必填(来自 gotry_artifacts_list)'
|
|
1443
|
+
}));
|
|
1444
|
+
const r = await readArtifact({
|
|
1445
|
+
stateRoot: config.stateRoot ?? '.',
|
|
1446
|
+
path: q.path,
|
|
1447
|
+
offset: q.offset,
|
|
1448
|
+
limit: q.limit
|
|
1449
|
+
});
|
|
1450
|
+
if (!r.ok) return JSON.parse(JSON.stringify(r));
|
|
1451
|
+
return JSON.parse(JSON.stringify({
|
|
1452
|
+
...r,
|
|
1453
|
+
summary: `${r.path}(${r.totalLines} 行)第 ${r.offset}-${r.offset + r.lines.length - 1} 行${r.windowed ? `(共 ${r.totalLines} 行,可翻页)` : ''}`
|
|
1454
|
+
}));
|
|
1455
|
+
},
|
|
1456
|
+
presentCall: (args)=>({
|
|
1457
|
+
card: 'generic',
|
|
1458
|
+
title: `读产物:${String(args.query?.path ?? '')}`,
|
|
1459
|
+
kind: 'read',
|
|
1460
|
+
rawInput: args.query
|
|
1461
|
+
}),
|
|
1462
|
+
presentResult: (_args, value)=>{
|
|
1463
|
+
const r = value;
|
|
1464
|
+
if (!r.ok) {
|
|
1465
|
+
return {
|
|
1466
|
+
card: 'generic',
|
|
1467
|
+
title: '读产物失败',
|
|
1468
|
+
content: [
|
|
1469
|
+
{
|
|
1470
|
+
type: 'text',
|
|
1471
|
+
text: String(r.error ?? '')
|
|
1472
|
+
}
|
|
1473
|
+
]
|
|
1474
|
+
};
|
|
1475
|
+
}
|
|
1476
|
+
return {
|
|
1477
|
+
card: 'read',
|
|
1478
|
+
title: r.path?.split('/').pop() ?? r.path ?? '',
|
|
1479
|
+
path: r.path ?? '',
|
|
1480
|
+
offset: r.offset ?? 1,
|
|
1481
|
+
lines: r.lines ?? [],
|
|
1482
|
+
totalLines: r.totalLines ?? 0,
|
|
1483
|
+
lang: r.lang,
|
|
1484
|
+
content: [
|
|
1485
|
+
{
|
|
1486
|
+
type: 'text',
|
|
1487
|
+
text: r.content ?? ''
|
|
1488
|
+
}
|
|
1489
|
+
]
|
|
1490
|
+
};
|
|
1491
|
+
}
|
|
1492
|
+
}));
|
|
1355
1493
|
}
|
|
1356
1494
|
|
|
1357
1495
|
|
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.15",
|
|
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,7 @@
|
|
|
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",
|
|
23
24
|
"ts/src/tool-packet.ts",
|
|
24
25
|
"ts/src/memory-capture.ts",
|
|
25
26
|
"ts/src/memory-utility.ts",
|
|
@@ -47,9 +48,11 @@
|
|
|
47
48
|
"ts/package.json",
|
|
48
49
|
"cordis.gotry-patch.yml",
|
|
49
50
|
"README.md",
|
|
51
|
+
"README.zh-CN.md",
|
|
50
52
|
"LICENSE",
|
|
51
53
|
"ts/capabilities/session-consent.ts",
|
|
52
|
-
"ts/capabilities/session-login.ts"
|
|
54
|
+
"ts/capabilities/session-login.ts",
|
|
55
|
+
"ts/capabilities/artifacts.ts"
|
|
53
56
|
],
|
|
54
57
|
"engines": {
|
|
55
58
|
"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
|
+
}
|
package/ts/capabilities/hbcli.ts
CHANGED
|
@@ -13,9 +13,11 @@
|
|
|
13
13
|
*/
|
|
14
14
|
|
|
15
15
|
import { spawn } from 'node:child_process'
|
|
16
|
+
import { homedir } from 'node:os'
|
|
17
|
+
import { join } from 'node:path'
|
|
16
18
|
|
|
17
19
|
export interface HbcliCallOptions {
|
|
18
|
-
/** hbcli 二进制路径(默认 'hbcli',依赖 PATH) */
|
|
20
|
+
/** hbcli 二进制路径(默认 'hbcli',依赖 PATH;~/.local/bin 等已知安装位自动回退) */
|
|
19
21
|
hbcliBin?: string
|
|
20
22
|
/** 超时(ms) */
|
|
21
23
|
timeoutMs?: number
|
|
@@ -43,23 +45,29 @@ export interface HbcliCallResult {
|
|
|
43
45
|
error?: string
|
|
44
46
|
}
|
|
45
47
|
|
|
46
|
-
/**
|
|
47
|
-
|
|
48
|
+
/**
|
|
49
|
+
* hbcli 二进制候选路径(gotry setup 按官方脚本装到 ~/.local/bin/hbcli,
|
|
50
|
+
* symlink 指向 ~/.staicli/current/hbcli——当 PATH 不含 ~/.local/bin 时裸名
|
|
51
|
+
* spawn 仍 ENOENT,按已知安装位回退)。仅对默认名 'hbcli' 扩展;显式自定义
|
|
52
|
+
* 名(如测试注入的不存在路径)不扩展,保持配置即所用的可测性。
|
|
53
|
+
*/
|
|
54
|
+
export function hbcliBinCandidates(bin: string, homeDir: string = homedir()): string[] {
|
|
55
|
+
if (bin !== 'hbcli') return [bin]
|
|
56
|
+
return [bin, join(homeDir, '.local/bin/hbcli'), join(homeDir, '.staicli/current/hbcli')]
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/** 单个候选的一次 spawn 封装:失败不抛,返回降级结果(spawnError 标记 ENOENT 类失败供上层换候选) */
|
|
60
|
+
function attemptHbcli(
|
|
61
|
+
bin: string,
|
|
48
62
|
args: string[],
|
|
49
|
-
opts: HbcliCallOptions
|
|
50
|
-
): Promise<HbcliCallResult> {
|
|
63
|
+
opts: Required<Pick<HbcliCallOptions, 'timeoutMs' | 'env'>> & { envVars: Record<string, string> },
|
|
64
|
+
): Promise<HbcliCallResult & { spawnError?: boolean }> {
|
|
51
65
|
const started = Date.now()
|
|
52
|
-
const bin = opts.hbcliBin ?? 'hbcli'
|
|
53
|
-
const timeoutMs = opts.timeoutMs ?? 15_000
|
|
54
|
-
const env = opts.env ?? 'uat'
|
|
55
|
-
const envVars: Record<string, string> = { HOTELBYTE_ENV: env }
|
|
56
|
-
if (opts.token) envVars['HOTELBYTE_TOKEN'] = opts.token
|
|
57
|
-
|
|
58
66
|
return new Promise((resolve) => {
|
|
59
67
|
let stdout = ''
|
|
60
68
|
let stderr = ''
|
|
61
69
|
let settled = false
|
|
62
|
-
const child = spawn(bin, args, { env: { ...process.env, ...envVars } })
|
|
70
|
+
const child = spawn(bin, args, { env: { ...process.env, ...opts.envVars } })
|
|
63
71
|
const timer = setTimeout(() => {
|
|
64
72
|
if (!settled) {
|
|
65
73
|
settled = true
|
|
@@ -67,10 +75,10 @@ export async function callHbcliJson(
|
|
|
67
75
|
resolve({
|
|
68
76
|
via: 'hbcli-error', exitCode: -1, result: null,
|
|
69
77
|
evidence: `[实时API:hbcli@timeout@${new Date().toISOString()}]`,
|
|
70
|
-
latencyMs: Date.now() - started, error: `timeout after ${timeoutMs}ms`,
|
|
78
|
+
latencyMs: Date.now() - started, error: `timeout after ${opts.timeoutMs}ms`,
|
|
71
79
|
})
|
|
72
80
|
}
|
|
73
|
-
}, timeoutMs)
|
|
81
|
+
}, opts.timeoutMs)
|
|
74
82
|
child.stdout.on('data', (d: Buffer) => { stdout += d.toString() })
|
|
75
83
|
child.stderr.on('data', (d: Buffer) => { stderr += d.toString() })
|
|
76
84
|
child.on('close', (code) => {
|
|
@@ -105,16 +113,35 @@ export async function callHbcliJson(
|
|
|
105
113
|
if (settled) return
|
|
106
114
|
settled = true
|
|
107
115
|
clearTimeout(timer)
|
|
108
|
-
// ENOENT (二进制不存在)
|
|
116
|
+
// ENOENT (二进制不存在) 等也走降级路径;spawnError 供上层按候选路径重试
|
|
109
117
|
resolve({
|
|
110
118
|
via: 'hbcli-error', exitCode: -1, result: null,
|
|
111
119
|
evidence: `[实时API:hbcli@spawn_error@${new Date().toISOString()}]`,
|
|
112
|
-
latencyMs: Date.now() - started, error: (e as Error).message,
|
|
120
|
+
latencyMs: Date.now() - started, error: (e as Error).message, spawnError: true,
|
|
113
121
|
})
|
|
114
122
|
})
|
|
115
123
|
})
|
|
116
124
|
}
|
|
117
125
|
|
|
126
|
+
/** 通用 hbcli JSON 调用封装:失败不抛,而是返回降级结果 */
|
|
127
|
+
export async function callHbcliJson(
|
|
128
|
+
args: string[],
|
|
129
|
+
opts: HbcliCallOptions = {},
|
|
130
|
+
): Promise<HbcliCallResult> {
|
|
131
|
+
const env = opts.env ?? 'uat'
|
|
132
|
+
const envVars: Record<string, string> = { HOTELBYTE_ENV: env }
|
|
133
|
+
if (opts.token) envVars['HOTELBYTE_TOKEN'] = opts.token
|
|
134
|
+
const callOpts = { timeoutMs: opts.timeoutMs ?? 15_000, env, envVars }
|
|
135
|
+
const candidates = hbcliBinCandidates(opts.hbcliBin ?? 'hbcli')
|
|
136
|
+
let last: HbcliCallResult & { spawnError?: boolean } | undefined
|
|
137
|
+
for (const bin of candidates) {
|
|
138
|
+
last = await attemptHbcli(bin, args, callOpts)
|
|
139
|
+
// spawn 级失败(ENOENT 等)且还有候选 → 换下一个已知安装位;其余失败(退码/超时)无重试意义
|
|
140
|
+
if (!(last.spawnError && candidates.indexOf(bin) < candidates.length - 1)) return last
|
|
141
|
+
}
|
|
142
|
+
return last!
|
|
143
|
+
}
|
|
144
|
+
|
|
118
145
|
/** 高层语义化封装:酒店列表查询(down-tier to 静态包 + 证据链标注) */
|
|
119
146
|
export async function searchHotels(
|
|
120
147
|
query: { destination: string; checkIn?: string; checkOut?: string; adults?: number },
|
|
@@ -129,6 +156,11 @@ export async function searchHotels(
|
|
|
129
156
|
if (live.via === 'hbcli-realtime') {
|
|
130
157
|
return { ...live, hotels: live.result, summary: `${query.destination}:hbcli 实时返回${query.checkIn || query.checkOut ? '(日期不传上游 list,以当前窗口房价返回)' : ''}` }
|
|
131
158
|
}
|
|
159
|
+
// 降级原因人话化(issue #24):hbcli 未安装时按 gotry setup 指引(npm 安装期已
|
|
160
|
+
// 自动跑过官方脚本;PATH 未含 ~/.local/bin 时上方候选路径也已兜住),
|
|
161
|
+
// 裸 "spawn hbcli ENOENT" 读起来像工具坏了——实际静态包降级是设计行为
|
|
162
|
+
const rawReason = live.error ?? live.via
|
|
163
|
+
const reason = /ENOENT/i.test(rawReason) ? '未安装 hbcli(可选实时源;npx gotry setup 可按官方脚本安装)' : rawReason
|
|
132
164
|
// 降级:读静态包,按目的地过滤命中的住宿块(issue #24)——整包倾倒会把无关场景
|
|
133
165
|
// (深圳/普吉/曼谷/云南/大理混装)灌给模型且不指明哪块相关;包内无该目的地时明示
|
|
134
166
|
// 「无数据」而不是伪装成可用结果。
|
|
@@ -144,17 +176,17 @@ export async function searchHotels(
|
|
|
144
176
|
return {
|
|
145
177
|
...live,
|
|
146
178
|
hotels: { stays: matched },
|
|
147
|
-
summary: `${query.destination}:hbcli
|
|
179
|
+
summary: `${query.destination}:hbcli 实时源不可用(${reason}),已降级到静态包(公开渠道估算,非实时),命中 ${matched.length} 个住宿块`,
|
|
148
180
|
}
|
|
149
181
|
}
|
|
150
182
|
return {
|
|
151
183
|
...live,
|
|
152
184
|
hotels: null,
|
|
153
|
-
summary: `${query.destination}:hbcli
|
|
185
|
+
summary: `${query.destination}:hbcli 实时源不可用(${reason}),且静态包无「${query.destination}」住宿数据(静态包仅覆盖内置场景)`,
|
|
154
186
|
}
|
|
155
187
|
} catch { /* 静态包读不到也优雅降级 */ }
|
|
156
188
|
}
|
|
157
|
-
return { ...live, summary: `${query.destination}:hbcli
|
|
189
|
+
return { ...live, summary: `${query.destination}:hbcli 实时源不可用(${reason})且无静态包(仅返回错误)` }
|
|
158
190
|
}
|
|
159
191
|
|
|
160
192
|
/** 高层语义化封装:目的地列表(无数据依赖,通常 hbcli dest 命令可独立调通) */
|