@rexvane/piweb 0.3.13 → 0.3.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/.next/BUILD_ID +1 -1
- package/.next/app-path-routes-manifest.json +6 -6
- package/.next/build-manifest.json +3 -3
- package/.next/prerender-manifest.json +3 -3
- package/.next/react-loadable-manifest.json +1 -1
- package/.next/required-server-files.js +3 -1
- package/.next/required-server-files.json +3 -1
- package/.next/server/app/_global-error/page_client-reference-manifest.js +1 -1
- package/.next/server/app/_global-error.html +1 -1
- package/.next/server/app/_global-error.rsc +1 -1
- package/.next/server/app/_global-error.segments/_full.segment.rsc +1 -1
- package/.next/server/app/_global-error.segments/_global-error/__PAGE__.segment.rsc +1 -1
- package/.next/server/app/_global-error.segments/_tree.segment.rsc +1 -1
- package/.next/server/app/_not-found/page_client-reference-manifest.js +1 -1
- package/.next/server/app/_not-found.html +1 -1
- package/.next/server/app/_not-found.rsc +1 -1
- package/.next/server/app/_not-found.segments/_full.segment.rsc +1 -1
- package/.next/server/app/_not-found.segments/_not-found/__PAGE__.segment.rsc +1 -1
- package/.next/server/app/_not-found.segments/_tree.segment.rsc +1 -1
- package/.next/server/app/api/agent/[id]/events/route.js.nft.json +1 -1
- package/.next/server/app/api/agent/[id]/route.js.nft.json +1 -1
- package/.next/server/app/api/agent/[id]/tree/route.js.nft.json +1 -1
- package/.next/server/app/api/agent/new/route.js.nft.json +1 -1
- package/.next/server/app/api/growth/route.js.nft.json +1 -1
- package/.next/server/app/api/models/route.js.nft.json +1 -1
- package/.next/server/app/api/plugins/route.js.nft.json +1 -1
- package/.next/server/app/api/prompts/route.js.nft.json +1 -1
- package/.next/server/app/api/security/route.js.nft.json +1 -1
- package/.next/server/app/api/sessions/[id]/export/route.js.nft.json +1 -1
- package/.next/server/app/api/sessions/[id]/route.js.nft.json +1 -1
- package/.next/server/app/api/sessions/import/route.js +3 -3
- package/.next/server/app/api/sessions/route.js.nft.json +1 -1
- package/.next/server/app/api/skills/route.js +2 -2
- package/.next/server/app/api/skills/route.js.nft.json +1 -1
- package/.next/server/app/api/update/route.js +1 -1
- package/.next/server/app/api/update/route.js.nft.json +1 -1
- package/.next/server/app/api/version/route.js +1 -1
- package/.next/server/app/api/workspaces/route.js.nft.json +1 -1
- package/.next/server/app/index.html +1 -1
- package/.next/server/app/index.rsc +3 -3
- package/.next/server/app/index.segments/__PAGE__.segment.rsc +3 -3
- package/.next/server/app/index.segments/_full.segment.rsc +3 -3
- package/.next/server/app/index.segments/_tree.segment.rsc +1 -1
- package/.next/server/app/login/page_client-reference-manifest.js +1 -1
- package/.next/server/app/login.html +1 -1
- package/.next/server/app/login.rsc +2 -2
- package/.next/server/app/login.segments/_full.segment.rsc +2 -2
- package/.next/server/app/login.segments/_tree.segment.rsc +1 -1
- package/.next/server/app/login.segments/login/__PAGE__.segment.rsc +2 -2
- package/.next/server/app/page_client-reference-manifest.js +1 -1
- package/.next/server/app-paths-manifest.json +6 -6
- package/.next/server/chunks/496.js +1 -1
- package/.next/server/chunks/686.js +1 -1
- package/.next/server/middleware-build-manifest.js +1 -1
- package/.next/server/middleware-react-loadable-manifest.js +1 -1
- package/.next/server/pages/404.html +1 -1
- package/.next/server/pages/500.html +1 -1
- package/.next/server/server-reference-manifest.json +1 -1
- package/.next/static/chunks/4632.0ed92ae42905ce40.js +1 -0
- package/.next/static/chunks/4686-7d694def920ff093.js +1 -0
- package/.next/static/chunks/{webpack-b1a572e319353f5d.js → webpack-42c5aae79b0e5d8c.js} +1 -1
- package/README.md +52 -8
- package/assets/archive.png +0 -0
- package/assets/growth-1.png +0 -0
- package/assets/growth-2.png +0 -0
- package/assets/import.png +0 -0
- package/assets/main.png +0 -0
- package/assets/outline.png +0 -0
- package/assets/prompts-1.png +0 -0
- package/assets/prompts-2.png +0 -0
- package/assets/providers-1.png +0 -0
- package/assets/providers-2.png +0 -0
- package/assets/providers-3.png +0 -0
- package/assets/trace-1.png +0 -0
- package/assets/trace-2.png +0 -0
- package/assets/trace-3.png +0 -0
- package/assets/trace-4.png +0 -0
- package/assets/viewer-1.png +0 -0
- package/assets/viewer-2.png +0 -0
- package/next.config.ts +6 -2
- package/package.json +7 -3
- package/scripts/launcher.mjs +1 -1
- package/scripts/managed-niubash-assets.mjs +146 -0
- package/scripts/prepare-niubash-packages.mjs +182 -0
- package/scripts/smoke-managed-niubash.mjs +62 -0
- package/scripts/verify-package.mjs +53 -0
- package/src/components/SessionImportSection.tsx +76 -23
- package/src/i18n.tsx +2 -0
- package/src/lib/agent-manager.ts +3 -0
- package/src/lib/managed-niubash.ts +227 -0
- package/src/lib/session-import/claude.ts +23 -2
- package/src/lib/session-import/codex.ts +61 -8
- package/src/lib/session-import/dsh.ts +12 -1
- package/src/lib/session-import/files.ts +14 -0
- package/src/lib/session-import/grok.ts +56 -23
- package/src/lib/session-import/types.ts +12 -0
- package/src/lib/skills-service.ts +51 -14
- package/.next/static/chunks/4632.d574c210ad78d065.js +0 -1
- package/.next/static/chunks/4686-0a4155f8488314b8.js +0 -1
- package/assets/showcase.png +0 -0
- package/bin/pi.js +0 -50
- /package/.next/static/{olV7jxKRN5VhfEK012WhV → DdBwPYoHBKtaJ97QIr6Co}/_buildManifest.js +0 -0
- /package/.next/static/{olV7jxKRN5VhfEK012WhV → DdBwPYoHBKtaJ97QIr6Co}/_ssgManifest.js +0 -0
|
@@ -7,10 +7,10 @@
|
|
|
7
7
|
*/
|
|
8
8
|
import path from "node:path";
|
|
9
9
|
import fs from "node:fs/promises";
|
|
10
|
-
import { eachJsonLine, listFiles, readHead, reachedLimit,
|
|
10
|
+
import { eachJsonLine, listFiles, readHead, readTail, reachedLimit, statMtime } from "./files";
|
|
11
11
|
import { codexRoot } from "./paths";
|
|
12
12
|
import type { ExternalSessionSummary, ImportedBlock, ImportedEntry, ImportedSession, ImportSourceModule, ScanOptions } from "./types";
|
|
13
|
-
import { cleanTitle, compactBlocks, outputBlocks, parseArguments, textBlock, toEpochMs } from "./types";
|
|
13
|
+
import { cleanTitle, compactBlocks, dataUrlImageBlock, outputBlocks, parseArguments, textBlock, toEpochMs } from "./types";
|
|
14
14
|
|
|
15
15
|
const ROOT = codexRoot;
|
|
16
16
|
|
|
@@ -37,6 +37,21 @@ interface Meta {
|
|
|
37
37
|
createdAt?: number;
|
|
38
38
|
model?: string;
|
|
39
39
|
provider?: string;
|
|
40
|
+
/** true = 这条 rollout 是别的线程派出来的(子代理),不是主对话 */
|
|
41
|
+
subagent?: boolean;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* 子代理线程的判定,依据 session_meta.payload(本机 51 个 rollout 里有 22 个是子代理):
|
|
46
|
+
* - parent_thread_id:它的存在就说明这条线程由另一条线程派生
|
|
47
|
+
* - source 是对象(`{"subagent":{…}}`)而主线程是字符串 "cli" / "vscode"
|
|
48
|
+
* - thread_source:主线程恒为 "user",子代理是 "subagent" / "guardian_review"
|
|
49
|
+
* 三个条件在本机实测完全一致(22 个子代理全部同时命中,29 个主线程一个都不命中)。
|
|
50
|
+
*/
|
|
51
|
+
function isSubagentMeta(payload: Record<string, unknown>): boolean {
|
|
52
|
+
if (typeof payload.parent_thread_id === "string" && payload.parent_thread_id) return true;
|
|
53
|
+
if (payload.source && typeof payload.source === "object") return true;
|
|
54
|
+
return typeof payload.thread_source === "string" && payload.thread_source !== "user";
|
|
40
55
|
}
|
|
41
56
|
|
|
42
57
|
function metaFrom(records: Record<string, unknown>[]): Meta {
|
|
@@ -49,6 +64,7 @@ function metaFrom(records: Record<string, unknown>[]): Meta {
|
|
|
49
64
|
meta.createdAt = toEpochMs(payload.timestamp) ?? toEpochMs(record.timestamp);
|
|
50
65
|
if (typeof payload.model_provider === "string") meta.provider = payload.model_provider;
|
|
51
66
|
if (typeof payload.model === "string") meta.model = payload.model;
|
|
67
|
+
if (isSubagentMeta(payload)) meta.subagent = true;
|
|
52
68
|
}
|
|
53
69
|
// 模型有时只在 turn_context 里出现
|
|
54
70
|
for (const record of records) {
|
|
@@ -64,6 +80,7 @@ function messageBlocks(payload: Record<string, unknown>): ImportedBlock[] {
|
|
|
64
80
|
if (!Array.isArray(content)) return compactBlocks([textBlock(typeof content === "string" ? content : undefined)]);
|
|
65
81
|
return compactBlocks(content.flatMap((raw): Array<ImportedBlock | null> => {
|
|
66
82
|
const block = raw as Record<string, unknown>;
|
|
83
|
+
if (block?.type === "input_image") return [dataUrlImageBlock(block.image_url, block.mime_type)];
|
|
67
84
|
if (typeof block?.text === "string") return [textBlock(block.text)];
|
|
68
85
|
return [];
|
|
69
86
|
}));
|
|
@@ -79,6 +96,17 @@ function reasoningText(payload: Record<string, unknown>): ImportedBlock | null {
|
|
|
79
96
|
return null;
|
|
80
97
|
}
|
|
81
98
|
|
|
99
|
+
/** 文件 mtime 可能因复制/恢复失真;Codex 每行自带时间,末尾记录才是权威的最近活动时间 */
|
|
100
|
+
function latestTimestamp(records: Record<string, unknown>[]): number | undefined {
|
|
101
|
+
let latest: number | undefined;
|
|
102
|
+
for (const record of records) {
|
|
103
|
+
const payload = record.payload as Record<string, unknown> | undefined;
|
|
104
|
+
const at = toEpochMs(record.timestamp) ?? toEpochMs(payload?.timestamp);
|
|
105
|
+
if (at !== undefined) latest = Math.max(latest ?? at, at);
|
|
106
|
+
}
|
|
107
|
+
return latest;
|
|
108
|
+
}
|
|
109
|
+
|
|
82
110
|
/** 标题:Codex 不写标题字段,用首条真人用户消息(rollout 头几十行里就有) */
|
|
83
111
|
function titleFrom(records: Record<string, unknown>[]): string | undefined {
|
|
84
112
|
for (const record of records) {
|
|
@@ -97,10 +125,22 @@ export const codexSource: ImportSourceModule = {
|
|
|
97
125
|
|
|
98
126
|
async scan(options?: ScanOptions): Promise<ExternalSessionSummary[]> {
|
|
99
127
|
const limit = options?.limit ?? 0;
|
|
100
|
-
const
|
|
128
|
+
const files = await listFiles(ROOT(), { match: (name) => name.startsWith("rollout-") && name.endsWith(".jsonl") });
|
|
129
|
+
// 只读每个文件末尾 64KB 就能按记录时间正确排序;不能先按 mtime 截断,否则复制过的旧文件会挤掉真正最近的会话。
|
|
130
|
+
const candidates = await Promise.all(files.map(async (file) => {
|
|
131
|
+
const mtime = await statMtime(file);
|
|
132
|
+
try {
|
|
133
|
+
const updatedAt = latestTimestamp([...eachJsonLine(await readTail(file))]);
|
|
134
|
+
return { file, mtime, updatedAt: updatedAt ?? mtime };
|
|
135
|
+
} catch {
|
|
136
|
+
return { file, mtime, updatedAt: mtime };
|
|
137
|
+
}
|
|
138
|
+
}));
|
|
139
|
+
candidates.sort((left, right) => (right.updatedAt ?? 0) - (left.updatedAt ?? 0));
|
|
140
|
+
|
|
101
141
|
const out: ExternalSessionSummary[] = [];
|
|
102
|
-
for (const { file, mtime } of candidates) {
|
|
103
|
-
if (reachedLimit(out.length, limit)) break;
|
|
142
|
+
for (const { file, mtime, updatedAt } of candidates) {
|
|
143
|
+
if (reachedLimit(out.length, limit)) break;
|
|
104
144
|
let head: string;
|
|
105
145
|
try {
|
|
106
146
|
head = await readHead(file);
|
|
@@ -109,6 +149,7 @@ export const codexSource: ImportSourceModule = {
|
|
|
109
149
|
}
|
|
110
150
|
const records = [...eachJsonLine(head)].slice(0, 300);
|
|
111
151
|
const meta = metaFrom(records);
|
|
152
|
+
if (meta.subagent) continue; // 主代理派给子代理的活,不算会话
|
|
112
153
|
const hasMessage = records.some((record) => record.type === "response_item");
|
|
113
154
|
if (!hasMessage) continue;
|
|
114
155
|
out.push({
|
|
@@ -119,7 +160,7 @@ export const codexSource: ImportSourceModule = {
|
|
|
119
160
|
model: meta.model,
|
|
120
161
|
provider: meta.provider,
|
|
121
162
|
createdAt: meta.createdAt ?? mtime,
|
|
122
|
-
updatedAt
|
|
163
|
+
updatedAt,
|
|
123
164
|
location: file,
|
|
124
165
|
});
|
|
125
166
|
}
|
|
@@ -137,11 +178,18 @@ export const codexSource: ImportSourceModule = {
|
|
|
137
178
|
const skipped: string[] = [];
|
|
138
179
|
let synthetic = 0;
|
|
139
180
|
let reasoningSkipped = 0;
|
|
181
|
+
let runtimeContext = 0;
|
|
140
182
|
let meta: Meta = { cwd: summary.projectPath, createdAt: summary.createdAt, model: summary.model, provider: summary.provider };
|
|
141
183
|
|
|
142
184
|
for (const record of eachJsonLine(text)) {
|
|
143
185
|
if (record.type === "session_meta") {
|
|
144
186
|
meta = { ...meta, ...metaFrom([record]) };
|
|
187
|
+
const payload = record.payload as Record<string, unknown> | undefined;
|
|
188
|
+
if (payload?.base_instructions) runtimeContext += 1;
|
|
189
|
+
continue;
|
|
190
|
+
}
|
|
191
|
+
if (record.type === "world_state" || record.type === "turn_context") {
|
|
192
|
+
runtimeContext += 1;
|
|
145
193
|
continue;
|
|
146
194
|
}
|
|
147
195
|
if (record.type !== "response_item") continue;
|
|
@@ -152,11 +200,15 @@ export const codexSource: ImportSourceModule = {
|
|
|
152
200
|
switch (payload.type) {
|
|
153
201
|
case "message": {
|
|
154
202
|
const role = payload.role;
|
|
155
|
-
if (role === "developer")
|
|
203
|
+
if (role === "developer") {
|
|
204
|
+
runtimeContext += 1; // 系统/开发者上下文由 pi 按当前工作区重新生成
|
|
205
|
+
continue;
|
|
206
|
+
}
|
|
156
207
|
const blocks = messageBlocks(payload);
|
|
157
208
|
const plain = blocks.filter((block) => block.type === "text").map((block) => block.text).join("\n").trim();
|
|
158
209
|
if (role === "user") {
|
|
159
|
-
|
|
210
|
+
// 只有图片的真人输入也必须保留;纯文本的环境/AGENTS 注入仍不冒充用户消息
|
|
211
|
+
if (!blocks.length || (plain && isSynthetic(plain))) {
|
|
160
212
|
synthetic += 1;
|
|
161
213
|
continue;
|
|
162
214
|
}
|
|
@@ -200,6 +252,7 @@ export const codexSource: ImportSourceModule = {
|
|
|
200
252
|
|
|
201
253
|
if (synthetic) skipped.push(`跳过 ${synthetic} 条合成输入`);
|
|
202
254
|
if (reasoningSkipped) skipped.push(`跳过 ${reasoningSkipped} 条仅含加密内容的推理记录`);
|
|
255
|
+
if (runtimeContext) skipped.push(`跳过 ${runtimeContext} 条源工具运行时上下文(pi 会按当前工作区重新生成)`);
|
|
203
256
|
if (!entries.some((entry) => entry.type === "message")) return null;
|
|
204
257
|
return { summary: { ...summary, ...meta, projectPath: meta.cwd, model: meta.model, provider: meta.provider }, entries, skipped };
|
|
205
258
|
},
|
|
@@ -36,6 +36,11 @@ interface DshHeader {
|
|
|
36
36
|
id?: string;
|
|
37
37
|
cwd?: string;
|
|
38
38
|
createdAt?: number;
|
|
39
|
+
/**
|
|
40
|
+
* 主代理为 0;> 0 表示这条会话是主代理派给子代理的活(本机 198 个会话里有 55 个)。
|
|
41
|
+
* 那是子任务,不该混进"我的对话"列表里。
|
|
42
|
+
*/
|
|
43
|
+
delegationDepth?: number;
|
|
39
44
|
}
|
|
40
45
|
|
|
41
46
|
function parseRecords(text: string): { header: DshHeader; records: Record<string, unknown>[] } {
|
|
@@ -51,7 +56,12 @@ function parseRecords(text: string): { header: DshHeader; records: Record<string
|
|
|
51
56
|
continue;
|
|
52
57
|
}
|
|
53
58
|
if (parsed.type === "session") {
|
|
54
|
-
header = {
|
|
59
|
+
header = {
|
|
60
|
+
id: typeof parsed.id === "string" ? parsed.id : undefined,
|
|
61
|
+
cwd: typeof parsed.cwd === "string" ? parsed.cwd : undefined,
|
|
62
|
+
createdAt: toEpochMs(parsed.createdAt),
|
|
63
|
+
delegationDepth: typeof parsed.delegationDepth === "number" ? parsed.delegationDepth : undefined,
|
|
64
|
+
};
|
|
55
65
|
continue;
|
|
56
66
|
}
|
|
57
67
|
records.push(parsed);
|
|
@@ -138,6 +148,7 @@ export const dshSource: ImportSourceModule = {
|
|
|
138
148
|
}
|
|
139
149
|
const { header, records } = parseRecords(head.text);
|
|
140
150
|
if (!header.id) continue;
|
|
151
|
+
if (header.delegationDepth) continue; // 主代理派给子代理的会话,不算"我的对话"
|
|
141
152
|
// 建了却从没用过的会话(只有 header 与几条配置):整个文件都解出来却没有消息,不该列进列表
|
|
142
153
|
if (head.complete && !records.some((record) => record.type === "user/message" || record.type === "assistant/message")) continue;
|
|
143
154
|
let title: string | undefined;
|
|
@@ -13,6 +13,20 @@ export async function readHead(file: string, bytes = 64 * 1024): Promise<string>
|
|
|
13
13
|
}
|
|
14
14
|
}
|
|
15
15
|
|
|
16
|
+
/** 读文件末尾若干字节;首行可能是被截断的半行,JSONL 解析时会自然跳过 */
|
|
17
|
+
export async function readTail(file: string, bytes = 64 * 1024): Promise<string> {
|
|
18
|
+
const handle = await fs.open(file, "r");
|
|
19
|
+
try {
|
|
20
|
+
const size = (await handle.stat()).size;
|
|
21
|
+
const length = Math.min(size, bytes);
|
|
22
|
+
const buffer = Buffer.alloc(length);
|
|
23
|
+
const { bytesRead } = await handle.read(buffer, 0, length, size - length);
|
|
24
|
+
return buffer.subarray(0, bytesRead).toString("utf8");
|
|
25
|
+
} finally {
|
|
26
|
+
await handle.close();
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
|
|
16
30
|
/** 逐行解析 JSONL;单行坏掉只跳过该行,不放弃整个文件 */
|
|
17
31
|
export function* eachJsonLine(text: string): Generator<Record<string, unknown>> {
|
|
18
32
|
for (const line of text.split("\n")) {
|
|
@@ -6,10 +6,10 @@
|
|
|
6
6
|
*/
|
|
7
7
|
import path from "node:path";
|
|
8
8
|
import fs from "node:fs/promises";
|
|
9
|
-
import { eachJsonLine, listFiles, readHead, reachedLimit,
|
|
9
|
+
import { eachJsonLine, listFiles, readHead, reachedLimit, statMtime } from "./files";
|
|
10
10
|
import { grokRoot } from "./paths";
|
|
11
11
|
import type { ExternalSessionSummary, ImportedBlock, ImportedEntry, ImportedSession, ImportSourceModule, ScanOptions } from "./types";
|
|
12
|
-
import { cleanTitle, compactBlocks, outputBlocks, parseArguments, textBlock, toEpochMs } from "./types";
|
|
12
|
+
import { cleanTitle, compactBlocks, dataUrlImageBlock, outputBlocks, parseArguments, textBlock, toEpochMs } from "./types";
|
|
13
13
|
|
|
14
14
|
const ROOT = grokRoot;
|
|
15
15
|
|
|
@@ -30,10 +30,10 @@ function decodeProjectDir(name: string): string | undefined {
|
|
|
30
30
|
function importable(records: Record<string, unknown>[]): boolean {
|
|
31
31
|
for (const record of records) {
|
|
32
32
|
if (record.type === "assistant" || record.type === "tool_result") return true;
|
|
33
|
-
if (record.type === "reasoning" &&
|
|
33
|
+
if (record.type === "reasoning" && reasoningText(record.summary)) return true;
|
|
34
34
|
if (record.type !== "user" || record.synthetic_reason) continue;
|
|
35
35
|
const blocks = userBlocks(record.content);
|
|
36
|
-
if (blocks.some((block) => block.type === "text" && block.text.replace(/<[^>]+>/g, "").trim())) return true;
|
|
36
|
+
if (blocks.some((block) => block.type === "image" || (block.type === "text" && block.text.replace(/<[^>]+>/g, "").trim()))) return true;
|
|
37
37
|
}
|
|
38
38
|
return false;
|
|
39
39
|
}
|
|
@@ -46,39 +46,70 @@ async function hasRealMessage(history: string): Promise<boolean> {
|
|
|
46
46
|
}
|
|
47
47
|
}
|
|
48
48
|
|
|
49
|
-
/** user 的 content
|
|
49
|
+
/** user 的 content 可能是字符串、文本块或 data URL 图片块 */
|
|
50
|
+
function userBlocks(content: unknown): ImportedBlock[] {
|
|
50
51
|
if (typeof content === "string") return compactBlocks([textBlock(content)]);
|
|
51
52
|
if (!Array.isArray(content)) return [];
|
|
52
53
|
return compactBlocks(content.flatMap((raw): Array<ImportedBlock | null> => {
|
|
53
54
|
if (typeof raw === "string") return [textBlock(raw)];
|
|
54
55
|
const block = raw as Record<string, unknown>;
|
|
55
56
|
if (typeof block?.text === "string") return [textBlock(block.text)];
|
|
57
|
+
if (block?.type === "image") return [dataUrlImageBlock(block.url, block.mime ?? block.mimeType)];
|
|
56
58
|
return [];
|
|
57
59
|
}));
|
|
58
60
|
}
|
|
59
61
|
|
|
62
|
+
/** Grok 新版把 reasoning.summary 从字符串改成了 summary_text 块数组;两代格式都要读 */
|
|
63
|
+
function reasoningText(summary: unknown): string {
|
|
64
|
+
if (typeof summary === "string") return summary.trim();
|
|
65
|
+
if (!Array.isArray(summary)) return "";
|
|
66
|
+
return summary
|
|
67
|
+
.map((item) => {
|
|
68
|
+
if (typeof item === "string") return item;
|
|
69
|
+
const block = item as { text?: unknown; summary_text?: unknown };
|
|
70
|
+
return typeof block?.text === "string" ? block.text : typeof block?.summary_text === "string" ? block.summary_text : "";
|
|
71
|
+
})
|
|
72
|
+
.filter(Boolean)
|
|
73
|
+
.join("\n")
|
|
74
|
+
.trim();
|
|
75
|
+
}
|
|
76
|
+
|
|
60
77
|
export const grokSource: ImportSourceModule = {
|
|
61
78
|
id: "grok",
|
|
62
79
|
|
|
63
80
|
async scan(options?: ScanOptions): Promise<ExternalSessionSummary[]> {
|
|
64
|
-
// 先按 chat_history 的 mtime 排序(只 stat,不读内容),再从最近往下读:
|
|
65
|
-
// summary.json 里有标题/模型/时间/消息数,读它即可,正文只在确认有真实对话时碰一下头部
|
|
66
81
|
const limit = options?.limit ?? 0;
|
|
67
82
|
const files = await listFiles(ROOT(), { match: (name) => name === "summary.json" });
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
const
|
|
73
|
-
let summary: Record<string, unknown>;
|
|
83
|
+
// summary.updated_at / last_active_at 是 Grok 的权威活动时间。必须先按它排序再 LIMIT,
|
|
84
|
+
// 不能用复制、恢复备份时会变化的 chat_history mtime 决定“最近 15 条”。
|
|
85
|
+
const candidates = (await Promise.all(files.map(async (summaryFile) => {
|
|
86
|
+
const dir = path.dirname(summaryFile);
|
|
87
|
+
const history = path.join(dir, "chat_history.jsonl");
|
|
74
88
|
try {
|
|
75
|
-
summary = JSON.parse(await fs.readFile(
|
|
89
|
+
const summary = JSON.parse(await fs.readFile(summaryFile, "utf8")) as Record<string, unknown>;
|
|
90
|
+
const mtime = await statMtime(history);
|
|
91
|
+
if (mtime === undefined) return null;
|
|
92
|
+
return {
|
|
93
|
+
dir,
|
|
94
|
+
history,
|
|
95
|
+
summary,
|
|
96
|
+
mtime,
|
|
97
|
+
updatedAt: toEpochMs(summary.updated_at ?? summary.last_active_at) ?? mtime,
|
|
98
|
+
};
|
|
76
99
|
} catch {
|
|
77
|
-
|
|
100
|
+
return null;
|
|
78
101
|
}
|
|
79
|
-
|
|
102
|
+
}))).filter((candidate): candidate is NonNullable<typeof candidate> => candidate !== null)
|
|
103
|
+
.sort((left, right) => right.updatedAt - left.updatedAt);
|
|
104
|
+
|
|
105
|
+
const out: ExternalSessionSummary[] = [];
|
|
106
|
+
for (const { dir, history, summary, mtime, updatedAt } of candidates) {
|
|
107
|
+
if (reachedLimit(out.length, limit)) break;
|
|
80
108
|
const info = summary.info as Record<string, unknown> | undefined;
|
|
81
|
-
|
|
109
|
+
// num_messages 包含 trace/内部事件;num_chat_messages 才对应 chat_history 的会话记录数
|
|
110
|
+
const numMessages = Number(summary.num_chat_messages ?? summary.num_messages);
|
|
111
|
+
// 子代理会话(grok 的 spawn_subagent:subagent / subagent_fork / subagent_resume,本机 24 条)
|
|
112
|
+
if (typeof summary.session_kind === "string" && summary.session_kind.startsWith("subagent")) continue;
|
|
82
113
|
if (numMessages === 0) continue; // 开了没用的空会话
|
|
83
114
|
// 只有系统提示与合成提醒(system_reminder)的会话读不出对话,别列进列表
|
|
84
115
|
if (!(await hasRealMessage(history))) continue;
|
|
@@ -89,7 +120,7 @@ export const grokSource: ImportSourceModule = {
|
|
|
89
120
|
projectPath: typeof info?.cwd === "string" ? info.cwd : decodeProjectDir(path.basename(path.dirname(dir))),
|
|
90
121
|
model: typeof summary.current_model_id === "string" ? summary.current_model_id : undefined,
|
|
91
122
|
createdAt: toEpochMs(summary.created_at) ?? mtime,
|
|
92
|
-
updatedAt
|
|
123
|
+
updatedAt,
|
|
93
124
|
...(Number.isFinite(numMessages) ? { messageCount: numMessages } : {}),
|
|
94
125
|
location: history,
|
|
95
126
|
});
|
|
@@ -108,17 +139,18 @@ export const grokSource: ImportSourceModule = {
|
|
|
108
139
|
const skipped: string[] = [];
|
|
109
140
|
let encryptedReasoning = 0;
|
|
110
141
|
let synthetic = 0;
|
|
142
|
+
let runtimeContext = 0;
|
|
111
143
|
let model = summary.model;
|
|
112
144
|
|
|
113
145
|
for (const record of eachJsonLine(text)) {
|
|
114
146
|
const timestamp = toEpochMs(record.timestamp ?? record.time ?? record.created_at) ?? summary.createdAt ?? Date.now();
|
|
115
147
|
switch (record.type) {
|
|
116
148
|
case "system":
|
|
117
|
-
|
|
149
|
+
runtimeContext += 1;
|
|
150
|
+
continue; // 系统提示由 pi 按目标工作区重新生成,不冒充用户消息
|
|
118
151
|
case "user": {
|
|
119
152
|
const blocks = userBlocks(record.content);
|
|
120
|
-
|
|
121
|
-
if (record.synthetic_reason || !plain) {
|
|
153
|
+
if (record.synthetic_reason || !blocks.length) {
|
|
122
154
|
synthetic += 1;
|
|
123
155
|
continue;
|
|
124
156
|
}
|
|
@@ -142,8 +174,8 @@ export const grokSource: ImportSourceModule = {
|
|
|
142
174
|
continue;
|
|
143
175
|
}
|
|
144
176
|
case "reasoning": {
|
|
145
|
-
const text =
|
|
146
|
-
if (text
|
|
177
|
+
const text = reasoningText(record.summary);
|
|
178
|
+
if (text) entries.push({ type: "message", message: { role: "assistant", content: [{ type: "thinking", thinking: text }], timestamp, ...(model ? { model } : {}) } });
|
|
147
179
|
else encryptedReasoning += 1; // 加密推理无正文可导:计数后汇总成一行,不要刷屏
|
|
148
180
|
continue;
|
|
149
181
|
}
|
|
@@ -160,6 +192,7 @@ export const grokSource: ImportSourceModule = {
|
|
|
160
192
|
|
|
161
193
|
if (synthetic) skipped.push(`跳过 ${synthetic} 条合成输入`);
|
|
162
194
|
if (encryptedReasoning) skipped.push(`跳过 ${encryptedReasoning} 条仅含加密内容的推理记录`);
|
|
195
|
+
if (runtimeContext) skipped.push(`跳过 ${runtimeContext} 条源工具运行时上下文(pi 会按当前工作区重新生成)`);
|
|
163
196
|
if (!entries.some((entry) => entry.type === "message")) return null;
|
|
164
197
|
return { summary: { ...summary, model }, entries, skipped };
|
|
165
198
|
},
|
|
@@ -121,6 +121,18 @@ export function compactBlocks(blocks: Array<ImportedBlock | null | undefined>):
|
|
|
121
121
|
return blocks.filter((block): block is ImportedBlock => Boolean(block));
|
|
122
122
|
}
|
|
123
123
|
|
|
124
|
+
/** 外部工具常把图片内联成 data URL;只接受 base64,避免把本地路径或远程 URL 塞进 pi 会话 */
|
|
125
|
+
export function dataUrlImageBlock(value: unknown, explicitMime?: unknown): ImportedBlock | null {
|
|
126
|
+
if (typeof value !== "string") return null;
|
|
127
|
+
const match = /^data:([^;,]+);base64,(.+)$/s.exec(value);
|
|
128
|
+
if (!match) return null;
|
|
129
|
+
return {
|
|
130
|
+
type: "image",
|
|
131
|
+
data: match[2],
|
|
132
|
+
mimeType: typeof explicitMime === "string" && explicitMime.trim() ? explicitMime : match[1],
|
|
133
|
+
};
|
|
134
|
+
}
|
|
135
|
+
|
|
124
136
|
/** 工具返回值可能是字符串、对象或数组,统一成内容块 */
|
|
125
137
|
export function outputBlocks(value: unknown): ImportedBlock[] {
|
|
126
138
|
if (typeof value === "string") return compactBlocks([textBlock(value)]);
|
|
@@ -18,13 +18,48 @@ export interface SkillView {
|
|
|
18
18
|
baseDir: string;
|
|
19
19
|
}
|
|
20
20
|
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
21
|
+
/**
|
|
22
|
+
* 路径比较用的规范形式:展开真实路径 → 统一分隔符 → 去掉结尾斜杠 → Windows 下大小写不敏感。
|
|
23
|
+
*
|
|
24
|
+
* realpath 这一步是必须的:同一个目录可以有多种写法——Windows 的 8.3 短名(`C:\Users\RUNNER~1`)、
|
|
25
|
+
* 含 `..` 的路径、junction / symlink。删技能、开关技能时 `resolveDiscoveredPath` 交出的是 **realpath 形式**,
|
|
26
|
+
* 而 agentDir 是配置里的原样字符串,两边直接比字符串就会把"本来就在 agent 目录下"的全局技能
|
|
27
|
+
* 判成 package 技能而拒绝删除(CI 的 Windows runner 上就是这样红的,真实用户只要路径里带短名也会中招)。
|
|
28
|
+
*/
|
|
29
|
+
async function toComparablePath(target: string): Promise<string> {
|
|
30
|
+
let resolved: string;
|
|
31
|
+
try {
|
|
32
|
+
resolved = await fs.realpath(target);
|
|
33
|
+
} catch {
|
|
34
|
+
resolved = path.resolve(target); // 路径还不存在(如待创建的技能目录):退化成普通规范化
|
|
35
|
+
}
|
|
36
|
+
return resolved.replace(/\\/g, "/").replace(/\/+$/, "").toLowerCase();
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
interface ScopeContext {
|
|
40
|
+
/** 全部已展开成可比较形式 */
|
|
41
|
+
agentDir: string;
|
|
42
|
+
home: string;
|
|
43
|
+
cwd?: string;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/** 每次调用只展开一次来源路径,避免逐个技能重复 realpath */
|
|
47
|
+
async function scopeContext(cwd?: string): Promise<ScopeContext> {
|
|
48
|
+
return {
|
|
49
|
+
agentDir: await toComparablePath(getAgentDir()),
|
|
50
|
+
home: await toComparablePath(os.homedir()),
|
|
51
|
+
cwd: cwd ? await toComparablePath(cwd) : undefined,
|
|
52
|
+
};
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/** 严格判断"在某个目录里面":必须落在分隔符之后,避免 /ws 误匹配 /ws-other */
|
|
56
|
+
const within = (target: string, root: string) => target.startsWith(`${root}/`);
|
|
57
|
+
|
|
58
|
+
async function classifyScope(filePath: string, context: ScopeContext): Promise<SkillView["scope"]> {
|
|
59
|
+
const norm = await toComparablePath(filePath);
|
|
60
|
+
if (within(norm, context.agentDir) || within(norm, `${context.home}/.agents`) || within(norm, `${context.home}/.pi`))
|
|
26
61
|
return "global";
|
|
27
|
-
if (cwd && norm
|
|
62
|
+
if (context.cwd && within(norm, context.cwd)) return "project";
|
|
28
63
|
if (norm.includes("/node_modules/")) return "package";
|
|
29
64
|
return "package";
|
|
30
65
|
}
|
|
@@ -37,6 +72,7 @@ export async function listSkills(cwd?: string): Promise<SkillView[]> {
|
|
|
37
72
|
await resourceLoaderReady(dir);
|
|
38
73
|
const loader = getResourceLoader(dir);
|
|
39
74
|
const { skills } = loader.getSkills();
|
|
75
|
+
const context = await scopeContext(dir);
|
|
40
76
|
for (const s of skills) {
|
|
41
77
|
if (seen.has(s.filePath)) continue;
|
|
42
78
|
seen.add(s.filePath);
|
|
@@ -45,7 +81,7 @@ export async function listSkills(cwd?: string): Promise<SkillView[]> {
|
|
|
45
81
|
description: s.description,
|
|
46
82
|
filePath: s.filePath,
|
|
47
83
|
disabled: s.disableModelInvocation,
|
|
48
|
-
scope: classifyScope(s.filePath,
|
|
84
|
+
scope: await classifyScope(s.filePath, context),
|
|
49
85
|
baseDir: s.baseDir,
|
|
50
86
|
});
|
|
51
87
|
}
|
|
@@ -85,7 +121,8 @@ export async function setSkillDisabled(filePath: string, disabled: boolean, cwd?
|
|
|
85
121
|
await fs.writeFile(authorized, next, "utf8");
|
|
86
122
|
// 就地重载加载器并让活跃会话重建系统提示(技能列表在系统提示里);全局技能影响所有目录
|
|
87
123
|
await reloadAllLoaders();
|
|
88
|
-
const
|
|
124
|
+
const context = await scopeContext(cwd);
|
|
125
|
+
const scope = await classifyScope(authorized, context);
|
|
89
126
|
await reloadSessionsForCwd(scope === "project" ? cwd : undefined);
|
|
90
127
|
return { changed: true };
|
|
91
128
|
}
|
|
@@ -100,21 +137,21 @@ export async function readSkillFile(filePath: string, cwd?: string): Promise<str
|
|
|
100
137
|
export async function deleteSkill(filePath: string, cwd?: string): Promise<{ removed: string }> {
|
|
101
138
|
const skills = await listSkills(cwd);
|
|
102
139
|
const authorized = await resolveDiscoveredPath(filePath, skills.map((skill) => skill.filePath));
|
|
103
|
-
|
|
140
|
+
const context = await scopeContext(cwd);
|
|
141
|
+
if ((await classifyScope(authorized, context)) === "package") {
|
|
104
142
|
throw new Error("package-managed skill: disable it instead (it is reinstalled with the package)");
|
|
105
143
|
}
|
|
106
144
|
const skillDir = path.dirname(authorized);
|
|
107
145
|
// 只删技能目录本身,不能误删父目录(全局技能目录 ~/.pi/agent/skills、项目 .agents/skills 等)
|
|
108
|
-
const
|
|
109
|
-
const
|
|
110
|
-
|
|
111
|
-
if (!knownRoots.some((root) => normalizedSkillDir.toLowerCase() === root.toLowerCase())) {
|
|
146
|
+
const normalizedSkillDir = await toComparablePath(skillDir);
|
|
147
|
+
const knownRoots = [`${context.agentDir}/skills`, `${context.agentDir}/web-skills`];
|
|
148
|
+
if (!knownRoots.includes(normalizedSkillDir)) {
|
|
112
149
|
// 非已知根:项目内技能目录,至少确认 SKILL.md 直接位于其下且目录名非空
|
|
113
150
|
if (path.basename(skillDir) === "") throw new Error("refusing to delete workspace root");
|
|
114
151
|
}
|
|
115
152
|
await fs.rm(skillDir, { recursive: true, force: true });
|
|
116
153
|
await reloadAllLoaders();
|
|
117
|
-
const scope = classifyScope(authorized,
|
|
154
|
+
const scope = await classifyScope(authorized, context);
|
|
118
155
|
await reloadSessionsForCwd(scope === "project" ? cwd : undefined);
|
|
119
156
|
return { removed: skillDir };
|
|
120
157
|
}
|