@danceiny/gotry 0.0.1-rc.14 → 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.
@@ -0,0 +1,153 @@
1
+ export const BOOKING_SAGA_SCHEMA = 'booking_saga_fsm.v1';
2
+ export const SAGA_STATUSES = [
3
+ 'pending',
4
+ 'confirmed',
5
+ 'compensated'
6
+ ];
7
+ export const SAGA_EDGES = [
8
+ {
9
+ from: 'none',
10
+ trigger: 'propose',
11
+ to: 'pending',
12
+ event: 'write.pending',
13
+ guard: 'L2 只登记不执行;idem_key UNIQUE(重复提议 no-op)'
14
+ },
15
+ {
16
+ from: 'pending',
17
+ trigger: 'confirm',
18
+ to: 'confirmed',
19
+ event: 'write.confirmed',
20
+ guard: 'L3 具名 seam 确认,必携 receipt;已确认不可再确认'
21
+ },
22
+ {
23
+ from: 'pending',
24
+ trigger: 'compensate',
25
+ to: 'compensated',
26
+ event: 'write.compensated',
27
+ guard: '外部写未发生,取消即终态(无补偿动作)'
28
+ },
29
+ {
30
+ from: 'confirmed',
31
+ trigger: 'compensate',
32
+ to: 'compensated',
33
+ event: 'write.compensated',
34
+ guard: '已发生副作用的 saga 补偿(退改),receipt 保留(COALESCE)'
35
+ }
36
+ ];
37
+ export const SAGA_REJECTIONS = [
38
+ {
39
+ from: 'none',
40
+ trigger: 'confirm',
41
+ reason: 'missing-subject'
42
+ },
43
+ {
44
+ from: 'none',
45
+ trigger: 'compensate',
46
+ reason: 'missing-subject'
47
+ },
48
+ {
49
+ from: 'pending',
50
+ trigger: 'propose',
51
+ reason: 'idem-exists'
52
+ },
53
+ {
54
+ from: 'confirmed',
55
+ trigger: 'propose',
56
+ reason: 'idem-exists'
57
+ },
58
+ {
59
+ from: 'confirmed',
60
+ trigger: 'confirm',
61
+ reason: 'already-confirmed'
62
+ },
63
+ {
64
+ from: 'compensated',
65
+ trigger: 'propose',
66
+ reason: 'idem-exists'
67
+ },
68
+ {
69
+ from: 'compensated',
70
+ trigger: 'confirm',
71
+ reason: 'absorbed-compensated'
72
+ },
73
+ {
74
+ from: 'compensated',
75
+ trigger: 'compensate',
76
+ reason: 'already-compensated'
77
+ }
78
+ ];
79
+ export function resolveSagaTrigger(origin, trigger) {
80
+ const edge = SAGA_EDGES.find((e)=>e.from === origin && e.trigger === trigger);
81
+ if (edge) return {
82
+ ok: true,
83
+ from: origin,
84
+ trigger,
85
+ to: edge.to,
86
+ event: edge.event,
87
+ guard: edge.guard,
88
+ reason: null
89
+ };
90
+ const rejection = SAGA_REJECTIONS.find((e)=>e.from === origin && e.trigger === trigger);
91
+ return {
92
+ ok: false,
93
+ from: origin,
94
+ trigger,
95
+ to: null,
96
+ event: null,
97
+ guard: null,
98
+ reason: rejection.reason
99
+ };
100
+ }
101
+ export const SAGA_TRIGGER_EVENT = {
102
+ propose: 'write.pending',
103
+ confirm: 'write.confirmed',
104
+ compensate: 'write.compensated'
105
+ };
106
+ export function triggerOfEventKind(kind) {
107
+ if (kind === 'write.pending') return 'propose';
108
+ if (kind === 'write.confirmed') return 'confirm';
109
+ if (kind === 'write.compensated') return 'compensate';
110
+ return null;
111
+ }
112
+ export function sagaTraceViolations(events) {
113
+ const violations = [];
114
+ let stage = 'none';
115
+ for (const ev of events){
116
+ const trigger = triggerOfEventKind(ev.kind);
117
+ if (!trigger) continue;
118
+ const verdict = resolveSagaTrigger(stage, trigger);
119
+ const at = `seq=${ev.seq ?? '?'} ${ev.kind}`;
120
+ if (!verdict.ok) {
121
+ violations.push(`${at}: 非法(${stage} --${trigger}--> 拒绝 ${verdict.reason})`);
122
+ continue;
123
+ }
124
+ stage = verdict.to;
125
+ if (trigger === 'confirm' && (ev.receipt === undefined || ev.receipt === null || ev.receipt === '')) {
126
+ violations.push(`${at}: receipt 为空(L3 具名确认必携外部回执)`);
127
+ }
128
+ }
129
+ return violations;
130
+ }
131
+ export function sagaLegalPaths() {
132
+ return [
133
+ [
134
+ 'propose'
135
+ ],
136
+ [
137
+ 'propose',
138
+ 'compensate'
139
+ ],
140
+ [
141
+ 'propose',
142
+ 'confirm'
143
+ ],
144
+ [
145
+ 'propose',
146
+ 'confirm',
147
+ 'compensate'
148
+ ]
149
+ ];
150
+ }
151
+
152
+
153
+ //# sourceURL=/Users/bytedance/work/gotry/ts/src/booking-saga.ts
@@ -3,7 +3,17 @@ import { buildTimeAnchor } from './time-anchor.js';
3
3
  import { buildSlotSystem, flagExpiredSlots, normalizeExtraction } from './travel-slots.js';
4
4
  const model = ()=>process.env['LLM_MODEL'] ?? process.env['DEEPSEEK_MODEL'] ?? 'MiniMax-M2';
5
5
  const base = ()=>(process.env['LLM_BASE_URL'] ?? process.env['DEEPSEEK_BASE_URL'] ?? 'https://api.minimax.io/v1').replace(/\/$/, '');
6
- async function chat(messages, json) {
6
+ function emptyUsage() {
7
+ return {
8
+ calls: 0,
9
+ inputTokens: 0,
10
+ outputTokens: 0,
11
+ inputCacheHitTokens: 0,
12
+ inputCacheMissTokens: 0,
13
+ responsesMissingUsage: 0
14
+ };
15
+ }
16
+ async function chat(messages, json, usage) {
7
17
  const key = process.env['LLM_API_KEY'] ?? process.env['DEEPSEEK_API_KEY'];
8
18
  if (!key) throw new Error('LLM_API_KEY 未设置(兼容 DEEPSEEK_API_KEY 别名)——真 LLM 路径不可用,请回退 mock(ADR-8)');
9
19
  const res = await fetch(`${base()}/chat/completions`, {
@@ -25,6 +35,18 @@ async function chat(messages, json) {
25
35
  });
26
36
  if (!res.ok) throw new Error(`llm ${res.status}: ${(await res.text()).slice(0, 300)}`);
27
37
  const data = await res.json();
38
+ if (usage) {
39
+ usage.calls += 1;
40
+ const u = data.usage;
41
+ if (u && Number.isFinite(u.prompt_tokens) && Number.isFinite(u.completion_tokens)) {
42
+ usage.inputTokens += u.prompt_tokens ?? 0;
43
+ usage.outputTokens += u.completion_tokens ?? 0;
44
+ usage.inputCacheHitTokens += u.prompt_cache_hit_tokens ?? 0;
45
+ usage.inputCacheMissTokens += u.prompt_cache_miss_tokens ?? u.prompt_tokens ?? 0;
46
+ } else {
47
+ usage.responsesMissingUsage += 1;
48
+ }
49
+ }
28
50
  const raw = data.choices[0]?.message?.content ?? '';
29
51
  return raw.replace(/<think>[\s\S]*?<\/think>/g, '').trim() || raw;
30
52
  }
@@ -50,6 +72,7 @@ const SKELETON_SYSTEM = `你是行程骨架抽取器。从对话中抽取行程
50
72
  scenario 判定:「洱海/大理/千岛湖/太湖+选目的地」→erhai(候选集);「普吉/workation/远程办公+多城链」→workation(五段链);「云南/大理丽江」→yunnan;不确定→generic。只输出 JSON。`;
51
73
  export function createOpenAICompatLlm(flightPackPath, clock = ()=>new Date()) {
52
74
  const pack = flightPackPath;
75
+ const usage = emptyUsage();
53
76
  const historyText = (h)=>h.map((t)=>`${t.role === 'user' ? '用户' : '助手'}: ${t.text}`).join('\n');
54
77
  const anchorContext = ()=>`时间锚点卡:\n${buildTimeAnchor(clock()).card}`;
55
78
  return {
@@ -63,7 +86,7 @@ export function createOpenAICompatLlm(flightPackPath, clock = ()=>new Date()) {
63
86
  role: 'user',
64
87
  content: `${anchorContext()}\n\n${historyText(history)}`
65
88
  }
66
- ], true);
89
+ ], true, usage);
67
90
  const obj = parseJsonBlock(out);
68
91
  if (!obj) return {
69
92
  assumptions: []
@@ -91,7 +114,7 @@ export function createOpenAICompatLlm(flightPackPath, clock = ()=>new Date()) {
91
114
  role: 'user',
92
115
  content: `${context}\n\n${historyText(history)}`
93
116
  }
94
- ], true);
117
+ ], true, usage);
95
118
  const skeleton = parseJsonBlock(out);
96
119
  if (!skeleton || !Array.isArray(skeleton['segments']) || skeleton['segments'].length === 0) return null;
97
120
  if (!pack) return null;
@@ -148,7 +171,7 @@ export function createOpenAICompatLlm(flightPackPath, clock = ()=>new Date()) {
148
171
  role: 'user',
149
172
  content: historyText(history)
150
173
  }
151
- ], true);
174
+ ], true, usage);
152
175
  const obj = parseJsonBlock(out);
153
176
  if (!obj) return null;
154
177
  const ext = normalizeExtraction(obj, history.map((t)=>t.text).join('\n'));
@@ -164,9 +187,10 @@ export function createOpenAICompatLlm(flightPackPath, clock = ()=>new Date()) {
164
187
  role: 'user',
165
188
  content: `【${q.key}】${q.text}(为什么问:${q.why})`
166
189
  }
167
- ], false);
190
+ ], false, usage);
168
191
  return out.trim() || `【${q.key}】${q.text}`;
169
- }
192
+ },
193
+ usage
170
194
  };
171
195
  }
172
196
 
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',
@@ -1357,6 +1358,138 @@ export function apply(ctx, config) {
1357
1358
  };
1358
1359
  }
1359
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
+ }));
1360
1493
  }
1361
1494
 
1362
1495
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@danceiny/gotry",
3
- "version": "0.0.1-rc.14",
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",
@@ -50,7 +51,8 @@
50
51
  "README.zh-CN.md",
51
52
  "LICENSE",
52
53
  "ts/capabilities/session-consent.ts",
53
- "ts/capabilities/session-login.ts"
54
+ "ts/capabilities/session-login.ts",
55
+ "ts/capabilities/artifacts.ts"
54
56
  ],
55
57
  "engines": {
56
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
+ }