pi-skill-evolve 0.1.3 → 0.1.5
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/extensions/skill-evolve/lib/aggregate.ts +153 -0
- package/extensions/skill-evolve/lib/command.ts +1 -1
- package/extensions/skill-evolve/lib/config.ts +7 -7
- package/extensions/skill-evolve/lib/dispatch-types.ts +65 -0
- package/extensions/skill-evolve/lib/dispatch.ts +32 -52
- package/extensions/skill-evolve/lib/evolution.ts +1 -0
- package/extensions/skill-evolve/lib/report.ts +4 -1
- package/extensions/skill-evolve/lib/runs.ts +35 -2
- package/extensions/skill-evolve/lib/shutdown-dispatch.ts +24 -1
- package/extensions/skill-evolve/lib/telemetry.ts +2 -0
- package/extensions/skill-evolve/lib/web/applied.ts +4 -0
- package/extensions/skill-evolve/lib/web/approvals.ts +4 -0
- package/extensions/skill-evolve/lib/web/data.ts +13 -226
- package/extensions/skill-evolve/lib/web/done.ts +65 -0
- package/extensions/skill-evolve/lib/web/filter.ts +4 -4
- package/extensions/skill-evolve/lib/web/pending.ts +191 -0
- package/extensions/skill-evolve/lib/web/render-rows.ts +37 -6
- package/extensions/skill-evolve/lib/web/render-run.ts +14 -0
- package/extensions/skill-evolve/lib/web/render-session.ts +67 -0
- package/extensions/skill-evolve/lib/web/reports.ts +11 -5
- package/extensions/skill-evolve/lib/web/run-detail.ts +6 -0
- package/extensions/skill-evolve/lib/web/run-session.ts +62 -0
- package/extensions/skill-evolve/lib/web/server.ts +13 -1
- package/extensions/skill-evolve/lib/web/session-file.ts +136 -0
- package/extensions/skill-evolve/lib/web/session-view.ts +260 -0
- package/extensions/skill-evolve/lib/web/style.ts +17 -0
- package/extensions/skill-evolve/test/aggregate-dispatch.test.ts +165 -0
- package/extensions/skill-evolve/test/aggregate-harness.ts +114 -0
- package/extensions/skill-evolve/test/aggregate.test.ts +80 -0
- package/extensions/skill-evolve/test/config.test.ts +10 -10
- package/extensions/skill-evolve/test/dispatch-supervisor.test.ts +2 -1
- package/extensions/skill-evolve/test/evolution.test.ts +6 -6
- package/extensions/skill-evolve/test/session-page.test.ts +194 -0
- package/extensions/skill-evolve/test/session-view.test.ts +192 -0
- package/extensions/skill-evolve/test/web-session-column.test.ts +141 -0
- package/package.json +1 -1
|
@@ -9,6 +9,7 @@
|
|
|
9
9
|
import type { WebState } from "./data.ts";
|
|
10
10
|
import { DIFF_PREVIEW_LINES, renderDiffLines, parseDiffLines } from "./diff-lines.ts";
|
|
11
11
|
import type { RunBlocking, RunDetail } from "./run-detail.ts";
|
|
12
|
+
import { sessionHref } from "./run-session.ts";
|
|
12
13
|
import { shellPage } from "./shell.ts";
|
|
13
14
|
import { escapeHtml, formatStamp } from "./text.ts";
|
|
14
15
|
|
|
@@ -21,6 +22,17 @@ function overview(detail: RunDetail): string {
|
|
|
21
22
|
const log = detail.hasChildLog
|
|
22
23
|
? `<a href="/log?runId=${encodeURIComponent(detail.runId)}">child.log</a>`
|
|
23
24
|
: `<span class="off">没有 child.log</span>`;
|
|
25
|
+
// 会话:完整 sessionId 做成「会话详情」链接(列表行上只有前 8 位,这里给全的)
|
|
26
|
+
const sessionFile = detail.sessionFile ?? null;
|
|
27
|
+
const href = sessionHref({ sessionId: detail.sessionId ?? null, sessionFile });
|
|
28
|
+
const session =
|
|
29
|
+
href === null
|
|
30
|
+
? `<span class="off">run.json 没写 sessionId</span>`
|
|
31
|
+
: `<a href="${escapeHtml(href)}"><code>${escapeHtml(detail.sessionId ?? "")}</code></a> <span class="hint">(点开看会话原文)</span>`;
|
|
32
|
+
const sessionFileCell =
|
|
33
|
+
sessionFile === null
|
|
34
|
+
? `<span class="off">run.json 没写 sessionFile</span>`
|
|
35
|
+
: `<code>${escapeHtml(sessionFile)}</code>`;
|
|
24
36
|
return `<section>
|
|
25
37
|
<h2>概要</h2>
|
|
26
38
|
<div class="kv">
|
|
@@ -28,6 +40,8 @@ ${kv("runId", `<code>${escapeHtml(detail.runId)}</code>`)}
|
|
|
28
40
|
${kv("状态", escapeHtml(detail.status))}
|
|
29
41
|
${kv("技能", escapeHtml(detail.skill.length > 0 ? detail.skill : "—"))}
|
|
30
42
|
${kv("触发", escapeHtml(detail.trigger))}
|
|
43
|
+
${kv("会话", session)}
|
|
44
|
+
${kv("会话文件", sessionFileCell)}
|
|
31
45
|
${kv("开始", escapeHtml(formatStamp(detail.startedAt)))}
|
|
32
46
|
${kv("结束", escapeHtml(formatStamp(detail.endedAt)) + (detail.endedAt === null ? "" : ' <span class="hint">(取最后一个产出时刻)</span>'))}
|
|
33
47
|
${kv("pid", detail.pid === null ? "—" : String(detail.pid))}
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 会话详情页(`?view=session&sessionId=...`)的渲染:概要 + 对话正文 + 原文件指路。
|
|
3
|
+
*
|
|
4
|
+
* 页面只有三段:顶部概要(完整 sessionId / 文件路径 / 统计)、中间正文(由 session-view 纯函数产出)、
|
|
5
|
+
* 底部一行「原文件 路径(N 条消息 / M 次工具调用 / 大小 K)」——
|
|
6
|
+
* 面板解析不了的情况(超大文件、非 JSONL)必须能一键回到原文件,不然这一页就成了死胡同。
|
|
7
|
+
*
|
|
8
|
+
* 找不到会话文件不报错:换机器 / 清理过 sessions 是常态,说清「会话文件已不在本机」比 500 有用。
|
|
9
|
+
*/
|
|
10
|
+
import type { WebState } from "./data.ts";
|
|
11
|
+
import type { SessionSource } from "./session-file.ts";
|
|
12
|
+
import { renderSessionBody } from "./session-view.ts";
|
|
13
|
+
import { shellPage } from "./shell.ts";
|
|
14
|
+
import { escapeHtml } from "./text.ts";
|
|
15
|
+
|
|
16
|
+
/** 概要的一行:标签 + 值(值必须已转义或本身就是标签串) */
|
|
17
|
+
function kv(label: string, value: string): string {
|
|
18
|
+
return `<div class="kv-row"><span class="k">${label}</span><span class="v">${value}</span></div>`;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/** 大小:小于 1KB 说字节,其余按 KB 取整(MB 级数字太长,看个量级就够) */
|
|
22
|
+
function formatSize(bytes: number): string {
|
|
23
|
+
return bytes < 1024 ? `${bytes}B` : `${Math.round(bytes / 1024)}KB`;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/** 缺文件时的正文:给三句人话,说明为什么没有、去哪里看 */
|
|
27
|
+
function missingBody(sessionId: string): string {
|
|
28
|
+
return `<p class="empty">会话文件已不在本机:sessionId <code>${escapeHtml(sessionId)}</code> 在
|
|
29
|
+
<code>~/.pi/agent/sessions/*</code> 下没有对应的 <code>.jsonl</code>。
|
|
30
|
+
可能是换了机器、或 sessions 目录被清理过 —— 这一段对话的原文已经取不到了。</p>`;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/** 正文:有文件就渲染对话,没文件就给缺文件说明 */
|
|
34
|
+
function bodyOf(source: SessionSource): string {
|
|
35
|
+
if (source.file === null || source.transcript === null) return missingBody(source.sessionId);
|
|
36
|
+
return renderSessionBody(source.transcript, { dropped: source.transcript.dropped });
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* 页脚那一行是这一页的「出口」:绝对路径可以直接粘到终端里 `rg` / `read`,
|
|
41
|
+
* 统计数字则用来说明「页面里看到的是全面的还是截断的」。
|
|
42
|
+
*/
|
|
43
|
+
function footerOf(source: SessionSource): string {
|
|
44
|
+
if (source.file === null || source.transcript === null) return `原文件:<span class="off">会话文件已不在本机</span>`;
|
|
45
|
+
const { messages, toolCalls } = source.transcript;
|
|
46
|
+
return `原文件 <code>${escapeHtml(source.file)}</code>(${messages} 条消息 / ${toolCalls} 次工具调用 / ${formatSize(source.sizeBytes)})`;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export function renderSessionPage(source: SessionSource, options: { state: WebState }): string {
|
|
50
|
+
const href = source.file === null ? null : source.file;
|
|
51
|
+
const body = `<section>
|
|
52
|
+
<h2>会话</h2>
|
|
53
|
+
<div class="kv">
|
|
54
|
+
${kv("sessionId", `<code>${escapeHtml(source.sessionId)}</code>`)}
|
|
55
|
+
${kv("会话文件", href === null ? `<span class="off">会话文件已不在本机</span>` : `<code>${escapeHtml(href)}</code>`)}
|
|
56
|
+
${kv("工具调用", "折叠块默认收起,点标题展开(参数摘要 ≤400 字、结果前 200 字)")}
|
|
57
|
+
</div>
|
|
58
|
+
</section>
|
|
59
|
+
${bodyOf(source)}`;
|
|
60
|
+
return shellPage({
|
|
61
|
+
state: options.state,
|
|
62
|
+
title: `会话 ${source.sessionId.slice(0, 8)}`,
|
|
63
|
+
active: null,
|
|
64
|
+
body,
|
|
65
|
+
foot: footerOf(source),
|
|
66
|
+
});
|
|
67
|
+
}
|
|
@@ -8,6 +8,7 @@ import { readVerify } from "../apply.ts";
|
|
|
8
8
|
import type { RunSummary } from "../runs.ts";
|
|
9
9
|
import { readRunError } from "../stale.ts";
|
|
10
10
|
import { asNumber, asRecord, asText, readJson, reasonLabel } from "./json-read.ts";
|
|
11
|
+
import { sessionFromRecord, type SessionRef } from "./run-session.ts";
|
|
11
12
|
|
|
12
13
|
/** 单条报告里最多列几个技能:全量列会把 details 撑成第二份 findings */
|
|
13
14
|
export const MAX_FINDING_ROWS = 5;
|
|
@@ -22,6 +23,8 @@ export interface RunReport {
|
|
|
22
23
|
runId: string;
|
|
23
24
|
status: string;
|
|
24
25
|
trigger: string;
|
|
26
|
+
/** 这条 run 是哪个会话跑出来的:行上「会话」列要用(缺 = NO_SESSION) */
|
|
27
|
+
session: SessionRef;
|
|
25
28
|
startedAt: number | null;
|
|
26
29
|
findings: { total: number; rows: FindingRow[] };
|
|
27
30
|
proposal: { type: string; skill: string; added: number; removed: number } | null;
|
|
@@ -84,10 +87,10 @@ function readVerifyGates(runDir: string): RunReport["verify"] {
|
|
|
84
87
|
};
|
|
85
88
|
}
|
|
86
89
|
|
|
87
|
-
/**
|
|
88
|
-
function
|
|
90
|
+
/** 触发者与会话只在 run.json 里(listRuns 的摘要不带 trigger):一次读出两样,坏记录当 unknown / 空 */
|
|
91
|
+
function readRunMeta(runDir: string): { trigger: string; session: SessionRef } {
|
|
89
92
|
const raw = readJson<Record<string, unknown>>(path.join(runDir, "run.json"));
|
|
90
|
-
return asText(raw?.trigger, "unknown");
|
|
93
|
+
return { trigger: asText(raw?.trigger, "unknown"), session: sessionFromRecord(raw) };
|
|
91
94
|
}
|
|
92
95
|
|
|
93
96
|
/** runs/ 下的目录名(不看 run.json,坏掉的目录也要能被 reports 的真实判据「有没有 01-findings」筛) */
|
|
@@ -117,6 +120,7 @@ export function buildReports(evolutionDir: string, runs: readonly RunSummary[]):
|
|
|
117
120
|
const summaries = new Map(runs.map((run) => [run.runId, run]));
|
|
118
121
|
const reports: RunReport[] = [];
|
|
119
122
|
for (const { runId, runDir } of runDirs(evolutionDir)) {
|
|
123
|
+
const meta = readRunMeta(runDir);
|
|
120
124
|
const findings = readFindings(runDir);
|
|
121
125
|
if (!findings) {
|
|
122
126
|
// 失败也要有一行档案:每条 run 都能在报告里找到,不能因为没产出就从历史里消失
|
|
@@ -129,7 +133,8 @@ export function buildReports(evolutionDir: string, runs: readonly RunSummary[]):
|
|
|
129
133
|
reports.push({
|
|
130
134
|
runId,
|
|
131
135
|
status: "stale",
|
|
132
|
-
trigger:
|
|
136
|
+
trigger: meta.trigger,
|
|
137
|
+
session: meta.session,
|
|
133
138
|
startedAt: summary?.dispatchedAt ?? asNumber(raw?.dispatchedAt),
|
|
134
139
|
findings: { total: 0, rows: [] },
|
|
135
140
|
proposal: null,
|
|
@@ -147,7 +152,8 @@ export function buildReports(evolutionDir: string, runs: readonly RunSummary[]):
|
|
|
147
152
|
reports.push({
|
|
148
153
|
runId,
|
|
149
154
|
status: summaries.get(runId)?.status ?? "unknown",
|
|
150
|
-
trigger:
|
|
155
|
+
trigger: meta.trigger,
|
|
156
|
+
session: meta.session,
|
|
151
157
|
startedAt: summaries.get(runId)?.dispatchedAt ?? null,
|
|
152
158
|
findings,
|
|
153
159
|
proposal: proposal ? { type: proposal.type, skill: proposal.skill, added: proposal.added, removed: proposal.removed } : null,
|
|
@@ -53,6 +53,10 @@ export interface RunDetail {
|
|
|
53
53
|
runId: string;
|
|
54
54
|
status: string;
|
|
55
55
|
trigger: string;
|
|
56
|
+
/** pi sessionId(这轮复盘是哪个会话跑出来的);老 run.json 可能没有 = null */
|
|
57
|
+
sessionId: string | null;
|
|
58
|
+
/** 会话原文 .jsonl 的绝对路径;没有 = null */
|
|
59
|
+
sessionFile: string | null;
|
|
56
60
|
/** 提案目标技能;没有提案时为空串 */
|
|
57
61
|
skill: string;
|
|
58
62
|
startedAt: number | null;
|
|
@@ -201,6 +205,8 @@ export function readRunDetail(evolutionDir: string, runId: string): RunDetail |
|
|
|
201
205
|
runId,
|
|
202
206
|
status: asText(record?.status, "(无 run.json)"),
|
|
203
207
|
trigger: asText(record?.trigger, "—"),
|
|
208
|
+
sessionId: asText(record?.sessionId) || null,
|
|
209
|
+
sessionFile: asText(record?.sessionFile) || null,
|
|
204
210
|
skill,
|
|
205
211
|
startedAt: asNumber(record?.dispatchedAt),
|
|
206
212
|
endedAt: stamps.length === 0 ? null : Math.max(...stamps),
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* run ↔ 会话的对应关系:面板每一行都要能回答「这条复盘是哪个会话跑出来的」。
|
|
3
|
+
*
|
|
4
|
+
* 为什么需要(用户 2026-11-08:「面板展示的也应该是 pi sessionId 否则我都不知道怎么看对应会话」):
|
|
5
|
+
* 原来行上只有 `runId`(会话哈希8 + 时间戳),对着 TUI 里的 sessionId 完全对不上号,
|
|
6
|
+
* 想回去看那段对话只能自己到 `~/.pi/agent/sessions/` 里翻。
|
|
7
|
+
*
|
|
8
|
+
* 纪律与 data.ts 一致:只读、坏记录当空(`sessionId` 缺失就当 null,绝不因为缺字段让整页 500)。
|
|
9
|
+
* 本文件只管**数据**,链接与列渲染在 render-rows.ts(渲染层)。
|
|
10
|
+
*/
|
|
11
|
+
import * as path from "node:path";
|
|
12
|
+
import type { RunSummary } from "../runs.ts";
|
|
13
|
+
import { asText, readJson } from "./json-read.ts";
|
|
14
|
+
|
|
15
|
+
export interface SessionRef {
|
|
16
|
+
/** pi 的 sessionId(UUID),缺失 = null */
|
|
17
|
+
sessionId: string | null;
|
|
18
|
+
/** 会话原文 .jsonl 的绝对路径(pi 写 run.json 时就带上了),缺失 = null */
|
|
19
|
+
sessionFile: string | null;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export const NO_SESSION: SessionRef = { sessionId: null, sessionFile: null };
|
|
23
|
+
|
|
24
|
+
/** 从已解析的 run.json 里取会话信息(空白串当缺失:不能让它变成一个空链接) */
|
|
25
|
+
export function sessionFromRecord(raw: unknown): SessionRef {
|
|
26
|
+
if (typeof raw !== "object" || raw === null) return NO_SESSION;
|
|
27
|
+
const record = raw as Record<string, unknown>;
|
|
28
|
+
const sessionId = asText(record.sessionId).trim();
|
|
29
|
+
const sessionFile = asText(record.sessionFile).trim();
|
|
30
|
+
return {
|
|
31
|
+
sessionId: sessionId.length > 0 ? sessionId : null,
|
|
32
|
+
sessionFile: sessionFile.length > 0 ? sessionFile : null,
|
|
33
|
+
};
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/** 读一条 run 的会话信息;run.json 读不到 / 坏掉都返回 NO_SESSION */
|
|
37
|
+
export function readRunSession(runDir: string): SessionRef {
|
|
38
|
+
return sessionFromRecord(readJson<Record<string, unknown>>(path.join(runDir, "run.json")));
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/** RunSummary → 会话信息(列表页「会话」列的数据源) */
|
|
42
|
+
export function sessionOfRun(run: Pick<RunSummary, "sessionId" | "sessionFile">): SessionRef {
|
|
43
|
+
return { sessionId: run.sessionId.length > 0 ? run.sessionId : null, sessionFile: run.sessionFile };
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/** runId → 会话信息:队列条目没有会话字段,只能按 runId 回推到 runs 里查 */
|
|
47
|
+
export function sessionIndex(runs: readonly Pick<RunSummary, "runId" | "sessionId" | "sessionFile">[]): Map<string, SessionRef> {
|
|
48
|
+
return new Map(runs.map((run) => [run.runId, sessionOfRun(run)]));
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/** 队列条目 → 会话信息;索引里没有它就现读一次 run.json(读不到 = NO_SESSION,链接不渲染) */
|
|
52
|
+
export function sessionForRun(runId: string, index: Map<string, SessionRef>, evolutionDir: string): SessionRef {
|
|
53
|
+
return index.get(runId) ?? readRunSession(path.join(evolutionDir, "runs", runId));
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/** `?view=session` 页的链接:sessionId 必有,文件路径可缺(缺了由服务端反查) */
|
|
57
|
+
export function sessionHref(session: SessionRef): string | null {
|
|
58
|
+
if (session.sessionId === null) return null;
|
|
59
|
+
const query = new URLSearchParams({ view: "session", sessionId: session.sessionId });
|
|
60
|
+
if (session.sessionFile !== null) query.set("file", session.sessionFile);
|
|
61
|
+
return `/?${query.toString()}`;
|
|
62
|
+
}
|
|
@@ -26,8 +26,10 @@ import { HTML_TYPE, MAX_BODY_BYTES, TEXT_TYPE, sendText } from "./http.ts";
|
|
|
26
26
|
import { clearWebPid, isAlive, readWebPid, writeWebPid } from "./panel-pid.ts";
|
|
27
27
|
import { renderPage } from "./render.ts";
|
|
28
28
|
import { renderRunPage } from "./render-run.ts";
|
|
29
|
+
import { renderSessionPage } from "./render-session.ts";
|
|
29
30
|
import { renderSkillPage, renderSkillsPage } from "./render-skills.ts";
|
|
30
31
|
import { handleDiff, readRunDetail } from "./run-detail.ts";
|
|
32
|
+
import { readSessionSource } from "./session-file.ts";
|
|
31
33
|
import { parseDays, readSkillDetail, readSkills } from "./skills.ts";
|
|
32
34
|
|
|
33
35
|
/** 默认端口:7788 是面板约定值(TUI 提示与文档里都写这个),改它得同时改文档 */
|
|
@@ -77,8 +79,9 @@ interface GetResult {
|
|
|
77
79
|
/**
|
|
78
80
|
* 读盘渲染:控制条、最近操作、筛选都在这里一次性读好传给渲染层(渲染层不碰磁盘)。
|
|
79
81
|
*
|
|
80
|
-
* 四类页面:`?view=run&runId=`(run 详情)/ `?view=skills`(技能管理)/ `?view=skill&name=`(技能详情)/ 五个列表页。
|
|
82
|
+
* 四类页面:`?view=run&runId=`(run 详情)/ `?view=session&sessionId=`(会话详情)/ `?view=skills`(技能管理)/ `?view=skill&name=`(技能详情)/ 五个列表页。
|
|
81
83
|
* 详情页的 runId 不合法或目录不存在一律 404 —— 靠 resolveRunDir 的目录校验,不靠字符串过滤。
|
|
84
|
+
* 会话页不走 404:会话文件可能已不在本机,那种情况页面要说人话(见 render-session.ts)。
|
|
82
85
|
*/
|
|
83
86
|
function renderGet(ctx: RouteContext, url: URL): GetResult {
|
|
84
87
|
const err = url.searchParams.get("err");
|
|
@@ -88,6 +91,15 @@ function renderGet(ctx: RouteContext, url: URL): GetResult {
|
|
|
88
91
|
const state = collectWebState({ evolutionDir: ctx.evolutionDir });
|
|
89
92
|
const view = parsePanelView(url.searchParams.get("view"));
|
|
90
93
|
const days = parseDays(url.searchParams.get("days"));
|
|
94
|
+
if (view === "session") {
|
|
95
|
+
const sessionId = url.searchParams.get("sessionId") ?? "";
|
|
96
|
+
const source = readSessionSource({
|
|
97
|
+
evolutionDir: ctx.evolutionDir,
|
|
98
|
+
sessionId,
|
|
99
|
+
...(url.searchParams.get("file") !== null ? { file: url.searchParams.get("file") } : {}),
|
|
100
|
+
});
|
|
101
|
+
return { status: 200, body: renderSessionPage(source, { state }), type: HTML_TYPE };
|
|
102
|
+
}
|
|
91
103
|
if (view === "run") {
|
|
92
104
|
const runId = url.searchParams.get("runId") ?? "";
|
|
93
105
|
const detail = readRunDetail(ctx.evolutionDir, runId);
|
|
@@ -0,0 +1,136 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 会话详情页的**读盘层**:从 sessionId 找到会话 .jsonl 绝对路径,读出来交给解析器。
|
|
3
|
+
*
|
|
4
|
+
* 两条来源(顺序固定):
|
|
5
|
+
* 1. run.json 里已经写着 `sessionFile`(pi 派发时写下的绝对路径);`?file=` 参数同理,但要**过安全闸**;
|
|
6
|
+
* 2. 该路径已不在本机(换了机器 / 清理过 sessions),就按 sessionId 在
|
|
7
|
+
* `~/.pi/agent/sessions/*/*.jsonl` 里按文件名反查 —— 文件名形如
|
|
8
|
+
* `2026-11-08T02-48-52-675Z_<sessionId>.jsonl`,所以「文件名含 sessionId」就是精确命中。
|
|
9
|
+
*
|
|
10
|
+
* 安全闸(面板虽然是 127.0.0.1,但 query 参数来自浏览器,不能当可信路径用):
|
|
11
|
+
* 只接受 **realpath 落在 sessions 根目录之下**、且**文件名含 sessionId**、且以 `.jsonl` 结尾的文件。
|
|
12
|
+
* 三条同时成立才读 —— 少了任何一条,`?file=/etc/passwd` 就能读到任意文件。
|
|
13
|
+
*/
|
|
14
|
+
import * as fs from "node:fs";
|
|
15
|
+
import * as os from "node:os";
|
|
16
|
+
import * as path from "node:path";
|
|
17
|
+
import { listRuns } from "../runs.ts";
|
|
18
|
+
import { parseSessionText, type SessionTranscript } from "./session-view.ts";
|
|
19
|
+
|
|
20
|
+
/** sessionId 只允许这些字符:它会被当文件名匹配词,绝不允许 `/` 之类进到路径里 */
|
|
21
|
+
const SESSION_ID_PATTERN = /^[A-Za-z0-9._-]{4,128}$/;
|
|
22
|
+
|
|
23
|
+
/** 反查 runs 时要扫多少条(与 data.ts 的 SCAN_LIMIT 同量级,够覆盖最近这批 run) */
|
|
24
|
+
const RUN_SCAN_LIMIT = 100;
|
|
25
|
+
|
|
26
|
+
export interface SessionSource {
|
|
27
|
+
sessionId: string;
|
|
28
|
+
/** 解析到的会话原文绝对路径;找不到 = null */
|
|
29
|
+
file: string | null;
|
|
30
|
+
/** 文件字节数(找不到 = 0) */
|
|
31
|
+
sizeBytes: number;
|
|
32
|
+
/** 解析结果;找不到文件 = null */
|
|
33
|
+
transcript: SessionTranscript | null;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/** pi 会话根目录:`~/.pi/agent/sessions`(测试用 home 覆盖,绝不读真实目录) */
|
|
37
|
+
export function sessionsRoot(home?: string): string {
|
|
38
|
+
return path.join(home ?? os.homedir(), ".pi", "agent", "sessions");
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/** 规范化 sessionId:不合法返回 null(调用方据此直接给「会话文件已不在本机」) */
|
|
42
|
+
export function safeSessionId(raw: string): string | null {
|
|
43
|
+
const trimmed = raw.trim();
|
|
44
|
+
return SESSION_ID_PATTERN.test(trimmed) ? trimmed : null;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/** 路径是否真在根目录之下(用 realpath 比,符号链接绕不出去) */
|
|
48
|
+
function insideRoot(root: string, file: string): boolean {
|
|
49
|
+
try {
|
|
50
|
+
const realRoot = fs.realpathSync(root);
|
|
51
|
+
const realFile = fs.realpathSync(file);
|
|
52
|
+
return realFile.startsWith(`${realRoot}${path.sep}`);
|
|
53
|
+
} catch {
|
|
54
|
+
return false;
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/** 文件本身是不是这个会话的原文:basename 含 sessionId、后缀 .jsonl、且在根目录内 */
|
|
59
|
+
function isSessionFile(root: string, sessionId: string, file: string): boolean {
|
|
60
|
+
if (!file.endsWith(".jsonl")) return false;
|
|
61
|
+
if (!path.basename(file).includes(sessionId)) return false;
|
|
62
|
+
return insideRoot(root, file);
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* 按 sessionId 在 sessions 根目录下反查(一层子目录,pi 按项目路径分目录)。
|
|
67
|
+
* 命中多条时取文件名最大的那条:名字以 ISO 时间开头,字典序最大 = 最近一次会话。
|
|
68
|
+
*/
|
|
69
|
+
export function findSessionFile(sessionId: string, root: string): string | null {
|
|
70
|
+
let dirs: fs.Dirent[];
|
|
71
|
+
try {
|
|
72
|
+
dirs = fs.readdirSync(root, { withFileTypes: true });
|
|
73
|
+
} catch {
|
|
74
|
+
return null;
|
|
75
|
+
}
|
|
76
|
+
let found: string | null = null;
|
|
77
|
+
for (const dir of dirs) {
|
|
78
|
+
if (!dir.isDirectory()) continue;
|
|
79
|
+
const sub = path.join(root, dir.name);
|
|
80
|
+
let names: string[];
|
|
81
|
+
try {
|
|
82
|
+
names = fs.readdirSync(sub);
|
|
83
|
+
} catch {
|
|
84
|
+
continue;
|
|
85
|
+
}
|
|
86
|
+
for (const name of names) {
|
|
87
|
+
if (!name.endsWith(".jsonl") || !name.includes(sessionId)) continue;
|
|
88
|
+
if (found === null || name > path.basename(found)) found = path.join(sub, name);
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
return found !== null && isSessionFile(root, sessionId, found) ? found : null;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/** 解析会话文件路径:先试给定路径(过安全闸),不行再按 sessionId 反查 */
|
|
95
|
+
export function resolveSessionFile(input: { sessionId: string; file?: string | null; root: string }): string | null {
|
|
96
|
+
const { sessionId, file, root } = input;
|
|
97
|
+
if (file !== null && file !== undefined && file.length > 0) {
|
|
98
|
+
if (isSessionFile(root, sessionId, file)) return file;
|
|
99
|
+
}
|
|
100
|
+
return findSessionFile(sessionId, root);
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/** run.json 里记着的会话路径:同 sessionId 的 run 里挑最近一条(队列条目没有这个字段,只能反查) */
|
|
104
|
+
export function declaredSessionFile(evolutionDir: string, sessionId: string): string | null {
|
|
105
|
+
const runs = listRuns(evolutionDir, RUN_SCAN_LIMIT);
|
|
106
|
+
for (const run of runs) {
|
|
107
|
+
if (run.sessionId === sessionId && run.sessionFile !== null && run.sessionFile.length > 0) return run.sessionFile;
|
|
108
|
+
}
|
|
109
|
+
return null;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/**
|
|
113
|
+
* 读出会话原文并解析。找不到文件不抛错:页面要显示「会话文件已不在本机」这句人话,
|
|
114
|
+
* 而不是一个 500 或空白页。
|
|
115
|
+
*/
|
|
116
|
+
export function readSessionSource(input: {
|
|
117
|
+
evolutionDir: string;
|
|
118
|
+
sessionId: string;
|
|
119
|
+
file?: string | null;
|
|
120
|
+
home?: string;
|
|
121
|
+
}): SessionSource {
|
|
122
|
+
const sessionId = safeSessionId(input.sessionId);
|
|
123
|
+
const root = sessionsRoot(input.home);
|
|
124
|
+
if (sessionId === null) return { sessionId: input.sessionId.trim(), file: null, sizeBytes: 0, transcript: null };
|
|
125
|
+
const declared = input.file ?? declaredSessionFile(input.evolutionDir, sessionId);
|
|
126
|
+
const file = resolveSessionFile({ sessionId, file: declared, root });
|
|
127
|
+
if (file === null) return { sessionId, file: null, sizeBytes: 0, transcript: null };
|
|
128
|
+
let raw: string;
|
|
129
|
+
try {
|
|
130
|
+
raw = fs.readFileSync(file, "utf8");
|
|
131
|
+
} catch {
|
|
132
|
+
// 目录在但文件被删 / 没权限:与「不在本机」同一句话,不暴露系统错误
|
|
133
|
+
return { sessionId, file: null, sizeBytes: 0, transcript: null };
|
|
134
|
+
}
|
|
135
|
+
return { sessionId, file, sizeBytes: Buffer.byteLength(raw, "utf8"), transcript: parseSessionText(raw) };
|
|
136
|
+
}
|