@rexvane/piweb 0.3.14 → 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 +5 -5
- 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.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.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.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 +1 -1
- package/.next/server/app/index.segments/__PAGE__.segment.rsc +1 -1
- package/.next/server/app/index.segments/_full.segment.rsc +1 -1
- package/.next/server/app/index.segments/_tree.segment.rsc +1 -1
- package/.next/server/app/login.html +1 -1
- package/.next/server/app/login.rsc +1 -1
- package/.next/server/app/login.segments/_full.segment.rsc +1 -1
- package/.next/server/app/login.segments/_tree.segment.rsc +1 -1
- package/.next/server/app/login.segments/login/__PAGE__.segment.rsc +1 -1
- package/.next/server/app-paths-manifest.json +5 -5
- 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/{webpack-d28e132e1b8c100c.js → webpack-42c5aae79b0e5d8c.js} +1 -1
- package/README.md +51 -7
- 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 +36 -20
- package/src/lib/agent-manager.ts +3 -0
- package/src/lib/managed-niubash.ts +227 -0
- package/src/lib/session-import/claude.ts +13 -2
- package/src/lib/session-import/codex.ts +44 -8
- package/src/lib/session-import/files.ts +14 -0
- package/src/lib/session-import/grok.ts +54 -24
- package/src/lib/session-import/types.ts +12 -0
- package/.next/static/chunks/4632.112d2e893d48d188.js +0 -1
- package/assets/showcase.png +0 -0
- package/bin/pi.js +0 -50
- /package/.next/static/{LOFl9bqqMV2goBV8erESO → DdBwPYoHBKtaJ97QIr6Co}/_buildManifest.js +0 -0
- /package/.next/static/{LOFl9bqqMV2goBV8erESO → DdBwPYoHBKtaJ97QIr6Co}/_ssgManifest.js +0 -0
|
@@ -0,0 +1,227 @@
|
|
|
1
|
+
import crypto from "node:crypto";
|
|
2
|
+
import fs from "node:fs";
|
|
3
|
+
import { createRequire } from "node:module";
|
|
4
|
+
import path from "node:path";
|
|
5
|
+
import { createBashToolDefinition, type ToolDefinition } from "@earendil-works/pi-coding-agent";
|
|
6
|
+
import { APP_ROOT } from "./app-root";
|
|
7
|
+
|
|
8
|
+
const DISTRIBUTION_VERSION = "1.1.4-piweb.0";
|
|
9
|
+
const NIUBASH_VERSION = "1.1.4";
|
|
10
|
+
const SOURCE_REPOSITORY = "https://github.com/unixwin/niubash";
|
|
11
|
+
const SOURCE_TAG = "v1.1.4";
|
|
12
|
+
|
|
13
|
+
const PACKAGE_BY_ARCH = {
|
|
14
|
+
x64: {
|
|
15
|
+
packageName: "@rexvane/piweb-niubash-win32-x64",
|
|
16
|
+
directory: "piweb-niubash-win32-x64",
|
|
17
|
+
asset: "niubash-v1.1.4-win-x64.zip",
|
|
18
|
+
assetSha256: "9f543634288a316f7ed8e5a76ff794eca402cb21e74b8f8a551daffafde162f5",
|
|
19
|
+
machine: 0x8664,
|
|
20
|
+
},
|
|
21
|
+
arm64: {
|
|
22
|
+
packageName: "@rexvane/piweb-niubash-win32-arm64",
|
|
23
|
+
directory: "piweb-niubash-win32-arm64",
|
|
24
|
+
asset: "niubash-v1.1.4-win-arm64.zip",
|
|
25
|
+
assetSha256: "77e98ba8d7f6be70c35388bcc28bdbc883c06bcaafffbe186a88788ad32ccf00",
|
|
26
|
+
machine: 0xaa64,
|
|
27
|
+
},
|
|
28
|
+
} as const;
|
|
29
|
+
|
|
30
|
+
const LICENSE_HASHES = {
|
|
31
|
+
"NIUBASH.txt": "1290aae99e71239ee3154e7847830e49e7a36c758fa6d659d73fa752dc9f81c8",
|
|
32
|
+
"RUBASH.txt": "e426a75a20633f3088da22aef61a5a44d06f1f884c600f6011d00702c3989079",
|
|
33
|
+
"WINUXCMD.txt": "8ffc4ed56f5e6ab8183a7ece2e3aa6ea4800b0a4e8170d11bd60856f2dd760f3",
|
|
34
|
+
} as const;
|
|
35
|
+
|
|
36
|
+
export type SupportedArch = keyof typeof PACKAGE_BY_ARCH;
|
|
37
|
+
|
|
38
|
+
type PackageExport = {
|
|
39
|
+
schema?: unknown;
|
|
40
|
+
packageName?: unknown;
|
|
41
|
+
distributionVersion?: unknown;
|
|
42
|
+
niubashVersion?: unknown;
|
|
43
|
+
platform?: unknown;
|
|
44
|
+
arch?: unknown;
|
|
45
|
+
runtimeDir?: unknown;
|
|
46
|
+
shellPath?: unknown;
|
|
47
|
+
manifestPath?: unknown;
|
|
48
|
+
};
|
|
49
|
+
|
|
50
|
+
type LoadedPackage = { entryPath: string; value: unknown };
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* npm 安装(全局或本地)普通情况下由 Node 解析:npm 会把可选包提升到任意祖先
|
|
54
|
+
* node_modules,所以解析必须从包自己的 package.json 出发。仓库检出(开发 / CI /
|
|
55
|
+
* 发布前的 runtime 准备)没有安装这一步,此时按约定找 platform-packages/<dir>——
|
|
56
|
+
* 这与平台包发布后的目录布局一一对应,不引入第二套解析规则。
|
|
57
|
+
*/
|
|
58
|
+
export function resolveManagedNiubashPackageEntry(packageName: string, arch: SupportedArch, root = APP_ROOT): LoadedPackage {
|
|
59
|
+
const require = createRequire(path.join(root, "package.json"));
|
|
60
|
+
try {
|
|
61
|
+
return { entryPath: require.resolve(packageName), value: require(packageName) };
|
|
62
|
+
} catch (error) {
|
|
63
|
+
const checkoutEntry = path.join(root, "platform-packages", PACKAGE_BY_ARCH[arch].directory, "index.cjs");
|
|
64
|
+
if (fs.existsSync(checkoutEntry)) return { entryPath: checkoutEntry, value: require(checkoutEntry) };
|
|
65
|
+
throw error;
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
export type ManagedNiubashRuntime = {
|
|
70
|
+
packageName: string;
|
|
71
|
+
distributionVersion: string;
|
|
72
|
+
niubashVersion: string;
|
|
73
|
+
arch: SupportedArch;
|
|
74
|
+
runtimeDir: string;
|
|
75
|
+
shellPath: string;
|
|
76
|
+
};
|
|
77
|
+
|
|
78
|
+
export type ManagedNiubashResolveOptions = {
|
|
79
|
+
platform?: string;
|
|
80
|
+
arch?: string;
|
|
81
|
+
/** Base for package resolution; tests and the prepare script point it at a checkout root. */
|
|
82
|
+
root?: string;
|
|
83
|
+
loadPackage?: (packageName: string, arch: SupportedArch) => LoadedPackage;
|
|
84
|
+
warn?: (message: string) => void;
|
|
85
|
+
};
|
|
86
|
+
|
|
87
|
+
function sha256File(filename: string): string {
|
|
88
|
+
return crypto.createHash("sha256").update(fs.readFileSync(filename)).digest("hex");
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
function readJson(filename: string): any {
|
|
92
|
+
const stat = fs.statSync(filename);
|
|
93
|
+
if (!stat.isFile() || stat.size > 2 * 1024 * 1024) throw new Error(`${path.basename(filename)} is missing or too large`);
|
|
94
|
+
return JSON.parse(fs.readFileSync(filename, "utf8"));
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
function samePath(left: string, right: string): boolean {
|
|
98
|
+
const a = path.resolve(left);
|
|
99
|
+
const b = path.resolve(right);
|
|
100
|
+
return process.platform === "win32" ? a.toLowerCase() === b.toLowerCase() : a === b;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
function peMachine(filename: string): number {
|
|
104
|
+
const data = fs.readFileSync(filename);
|
|
105
|
+
if (data.length < 64 || data[0] !== 0x4d || data[1] !== 0x5a) throw new Error(`${path.basename(filename)} is not a PE executable`);
|
|
106
|
+
const offset = data.readUInt32LE(0x3c);
|
|
107
|
+
if (offset + 6 > data.length || data.toString("ascii", offset, offset + 4) !== "PE\0\0") {
|
|
108
|
+
throw new Error(`${path.basename(filename)} has an invalid PE header`);
|
|
109
|
+
}
|
|
110
|
+
return data.readUInt16LE(offset + 4);
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
function validatePackage(loaded: LoadedPackage, arch: SupportedArch): ManagedNiubashRuntime {
|
|
114
|
+
const expected = PACKAGE_BY_ARCH[arch];
|
|
115
|
+
if (!loaded.value || typeof loaded.value !== "object") throw new Error(`${expected.packageName} has no runtime export`);
|
|
116
|
+
const exported = loaded.value as PackageExport;
|
|
117
|
+
const packageRoot = path.dirname(path.resolve(loaded.entryPath));
|
|
118
|
+
const runtimeDir = path.join(packageRoot, "runtime");
|
|
119
|
+
const shellPath = path.join(runtimeDir, "niu.exe");
|
|
120
|
+
const manifestPath = path.join(packageRoot, "runtime-manifest.json");
|
|
121
|
+
|
|
122
|
+
const checks: Array<[unknown, unknown, string]> = [
|
|
123
|
+
[exported.schema, 1, "export schema"],
|
|
124
|
+
[exported.packageName, expected.packageName, "export package name"],
|
|
125
|
+
[exported.distributionVersion, DISTRIBUTION_VERSION, "export distribution version"],
|
|
126
|
+
[exported.niubashVersion, NIUBASH_VERSION, "export niubash version"],
|
|
127
|
+
[exported.platform, "win32", "export platform"],
|
|
128
|
+
[exported.arch, arch, "export architecture"],
|
|
129
|
+
];
|
|
130
|
+
for (const [actual, wanted, label] of checks) {
|
|
131
|
+
if (actual !== wanted) throw new Error(`${label} mismatch`);
|
|
132
|
+
}
|
|
133
|
+
if (typeof exported.runtimeDir !== "string" || !samePath(exported.runtimeDir, runtimeDir)) throw new Error("runtime directory escapes its package");
|
|
134
|
+
if (typeof exported.shellPath !== "string" || !samePath(exported.shellPath, shellPath)) throw new Error("shell path escapes its package");
|
|
135
|
+
if (typeof exported.manifestPath !== "string" || !samePath(exported.manifestPath, manifestPath)) throw new Error("manifest path escapes its package");
|
|
136
|
+
|
|
137
|
+
const packageInfo = readJson(path.join(packageRoot, "package.json"));
|
|
138
|
+
if (packageInfo.name !== expected.packageName || packageInfo.version !== DISTRIBUTION_VERSION) throw new Error("package identity mismatch");
|
|
139
|
+
if (packageInfo.os?.length !== 1 || packageInfo.os[0] !== "win32") throw new Error("package OS restriction mismatch");
|
|
140
|
+
if (packageInfo.cpu?.length !== 1 || packageInfo.cpu[0] !== arch) throw new Error("package CPU restriction mismatch");
|
|
141
|
+
|
|
142
|
+
const manifest = readJson(manifestPath);
|
|
143
|
+
if (manifest.schema !== 1
|
|
144
|
+
|| manifest.source?.repository !== SOURCE_REPOSITORY
|
|
145
|
+
|| manifest.source?.tag !== SOURCE_TAG
|
|
146
|
+
|| manifest.source?.asset !== expected.asset
|
|
147
|
+
|| manifest.source?.sha256 !== expected.assetSha256) {
|
|
148
|
+
throw new Error("runtime provenance mismatch");
|
|
149
|
+
}
|
|
150
|
+
if (!manifest.files || typeof manifest.files !== "object" || Array.isArray(manifest.files)) throw new Error("runtime file manifest is missing");
|
|
151
|
+
const files = Object.entries(manifest.files) as Array<[string, unknown]>;
|
|
152
|
+
if (files.length < 300 || files.length > 1000) throw new Error(`runtime file count is invalid (${files.length})`);
|
|
153
|
+
for (const [relative, digest] of files) {
|
|
154
|
+
if (!relative || relative.includes("\\") || path.posix.isAbsolute(relative) || /^[A-Za-z]:/.test(relative) || relative.split("/").includes("..")) {
|
|
155
|
+
throw new Error(`unsafe runtime path: ${relative}`);
|
|
156
|
+
}
|
|
157
|
+
if (typeof digest !== "string" || !/^[a-f0-9]{64}$/.test(digest)) throw new Error(`invalid runtime hash: ${relative}`);
|
|
158
|
+
const filename = path.join(runtimeDir, ...relative.split("/"));
|
|
159
|
+
if (!fs.statSync(filename).isFile()) throw new Error(`runtime file is missing: ${relative}`);
|
|
160
|
+
if (sha256File(filename) !== digest) throw new Error(`runtime file failed integrity check: ${relative}`);
|
|
161
|
+
}
|
|
162
|
+
for (const required of ["niu.exe", "winuxcmd/usr/bin/winuxcmd.exe", "bundles/oh-my-niu/bundle.toml"]) {
|
|
163
|
+
if (!(required in manifest.files)) throw new Error(`runtime file is missing: ${required}`);
|
|
164
|
+
}
|
|
165
|
+
for (const executable of [shellPath, path.join(runtimeDir, "winuxcmd", "usr", "bin", "winuxcmd.exe")]) {
|
|
166
|
+
if (peMachine(executable) !== expected.machine) throw new Error(`${path.basename(executable)} architecture mismatch`);
|
|
167
|
+
}
|
|
168
|
+
for (const [name, digest] of Object.entries(LICENSE_HASHES)) {
|
|
169
|
+
if (sha256File(path.join(packageRoot, "licenses", name)) !== digest) throw new Error(`license integrity check failed: ${name}`);
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
return Object.freeze({
|
|
173
|
+
packageName: expected.packageName,
|
|
174
|
+
distributionVersion: DISTRIBUTION_VERSION,
|
|
175
|
+
niubashVersion: NIUBASH_VERSION,
|
|
176
|
+
arch,
|
|
177
|
+
runtimeDir,
|
|
178
|
+
shellPath,
|
|
179
|
+
});
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
function reasonOf(error: unknown, packageName: string): string {
|
|
183
|
+
if ((error as NodeJS.ErrnoException | undefined)?.code === "MODULE_NOT_FOUND") return `${packageName} is not installed`;
|
|
184
|
+
return error instanceof Error ? error.message : String(error);
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
/** Resolve and fully validate the platform package. Failures deliberately fall back to Pi's normal Bash. */
|
|
188
|
+
export function resolveManagedNiubash(options: ManagedNiubashResolveOptions = {}): ManagedNiubashRuntime | null {
|
|
189
|
+
const platform = options.platform ?? process.platform;
|
|
190
|
+
const arch = options.arch ?? process.arch;
|
|
191
|
+
if (platform !== "win32") return null;
|
|
192
|
+
const expected = PACKAGE_BY_ARCH[arch as SupportedArch];
|
|
193
|
+
if (!expected) {
|
|
194
|
+
(options.warn ?? console.warn)(`[piweb] Managed niubash does not support Windows ${arch}; using Pi's configured/default Bash.`);
|
|
195
|
+
return null;
|
|
196
|
+
}
|
|
197
|
+
const supported = arch as SupportedArch;
|
|
198
|
+
try {
|
|
199
|
+
const loaded = options.loadPackage
|
|
200
|
+
? options.loadPackage(expected.packageName, supported)
|
|
201
|
+
: resolveManagedNiubashPackageEntry(expected.packageName, supported, options.root);
|
|
202
|
+
return validatePackage(loaded, supported);
|
|
203
|
+
} catch (error) {
|
|
204
|
+
(options.warn ?? console.warn)(
|
|
205
|
+
`[piweb] Managed niubash is unavailable (${reasonOf(error, expected.packageName)}); using Pi's configured/default Bash. `
|
|
206
|
+
+ "Reinstall @rexvane/piweb to restore the bundled Windows shell (`npm run prepare:niubash` in a checkout).",
|
|
207
|
+
);
|
|
208
|
+
return null;
|
|
209
|
+
}
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
const globalForManagedNiubash = globalThis as typeof globalThis & {
|
|
213
|
+
__piWebManagedNiubash?: { resolved: ManagedNiubashRuntime | null };
|
|
214
|
+
};
|
|
215
|
+
|
|
216
|
+
function defaultRuntime(): ManagedNiubashRuntime | null {
|
|
217
|
+
if (!globalForManagedNiubash.__piWebManagedNiubash) {
|
|
218
|
+
globalForManagedNiubash.__piWebManagedNiubash = { resolved: resolveManagedNiubash() };
|
|
219
|
+
}
|
|
220
|
+
return globalForManagedNiubash.__piWebManagedNiubash.resolved;
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
/** Same-name custom tool intentionally replaces Pi's built-in bash only inside PiWeb sessions. */
|
|
224
|
+
export function createManagedNiubashTools(cwd: string, options?: ManagedNiubashResolveOptions): ToolDefinition[] {
|
|
225
|
+
const runtime = options ? resolveManagedNiubash(options) : defaultRuntime();
|
|
226
|
+
return runtime ? [createBashToolDefinition(cwd, { shellPath: runtime.shellPath }) as ToolDefinition] : [];
|
|
227
|
+
}
|
|
@@ -160,6 +160,7 @@ export const claudeSource: ImportSourceModule = {
|
|
|
160
160
|
const skipped: string[] = [];
|
|
161
161
|
let sidechains = 0;
|
|
162
162
|
let synthetic = 0;
|
|
163
|
+
let runtimeContext = 0;
|
|
163
164
|
let projectPath = summary.projectPath;
|
|
164
165
|
let model = summary.model;
|
|
165
166
|
let provider: string | undefined;
|
|
@@ -169,6 +170,12 @@ export const claudeSource: ImportSourceModule = {
|
|
|
169
170
|
sidechains += 1;
|
|
170
171
|
continue;
|
|
171
172
|
}
|
|
173
|
+
// Claude 的 attachment/system 是技能列表、token 提醒、环境快照等运行时上下文;
|
|
174
|
+
// pi 会按目标工作区生成自己的版本,不能伪装成真人 user 消息,但要在报告中明确告知。
|
|
175
|
+
if (record.type === "attachment" || record.type === "system") {
|
|
176
|
+
runtimeContext += 1;
|
|
177
|
+
continue;
|
|
178
|
+
}
|
|
172
179
|
if (typeof record.cwd === "string" && record.cwd.trim()) projectPath = projectPath ?? record.cwd;
|
|
173
180
|
const message = record.message as Record<string, unknown> | undefined;
|
|
174
181
|
if (!message) continue;
|
|
@@ -206,9 +213,12 @@ export const claudeSource: ImportSourceModule = {
|
|
|
206
213
|
if (record.type === "user") {
|
|
207
214
|
const { text: textBlocks, results } = splitUserContent((message as { content?: unknown }).content);
|
|
208
215
|
const plain = textBlocks.filter((block) => block.type === "text").map((block) => block.text).join("\n");
|
|
209
|
-
const
|
|
216
|
+
const nonText = textBlocks.filter((block) => block.type !== "text");
|
|
210
217
|
if (plain && !isSynthetic(plain.trim()) && !/^Caveat:/i.test(plain.trim())) {
|
|
211
|
-
entries.push({ type: "message", message: { role: "user", content: [...
|
|
218
|
+
entries.push({ type: "message", message: { role: "user", content: [...nonText, { type: "text", text: plain }], timestamp } as ImportedMessage });
|
|
219
|
+
} else if (!plain && nonText.length) {
|
|
220
|
+
// 图片可以是用户消息的全部内容,不能因为没有文字就把整轮丢掉
|
|
221
|
+
entries.push({ type: "message", message: { role: "user", content: nonText, timestamp } as ImportedMessage });
|
|
212
222
|
} else if (plain) {
|
|
213
223
|
synthetic += 1;
|
|
214
224
|
}
|
|
@@ -223,6 +233,7 @@ export const claudeSource: ImportSourceModule = {
|
|
|
223
233
|
|
|
224
234
|
if (sidechains) skipped.push(`跳过 ${sidechains} 条侧链(子代理)记录`);
|
|
225
235
|
if (synthetic) skipped.push(`跳过 ${synthetic} 条合成输入(命令回显/系统提醒)`);
|
|
236
|
+
if (runtimeContext) skipped.push(`跳过 ${runtimeContext} 条源工具运行时上下文(pi 会按当前工作区重新生成)`);
|
|
226
237
|
if (!entries.some((entry) => entry.type === "message")) return null;
|
|
227
238
|
return { summary: { ...summary, projectPath, model, provider }, entries, skipped };
|
|
228
239
|
},
|
|
@@ -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
|
|
|
@@ -80,6 +80,7 @@ function messageBlocks(payload: Record<string, unknown>): ImportedBlock[] {
|
|
|
80
80
|
if (!Array.isArray(content)) return compactBlocks([textBlock(typeof content === "string" ? content : undefined)]);
|
|
81
81
|
return compactBlocks(content.flatMap((raw): Array<ImportedBlock | null> => {
|
|
82
82
|
const block = raw as Record<string, unknown>;
|
|
83
|
+
if (block?.type === "input_image") return [dataUrlImageBlock(block.image_url, block.mime_type)];
|
|
83
84
|
if (typeof block?.text === "string") return [textBlock(block.text)];
|
|
84
85
|
return [];
|
|
85
86
|
}));
|
|
@@ -95,6 +96,17 @@ function reasoningText(payload: Record<string, unknown>): ImportedBlock | null {
|
|
|
95
96
|
return null;
|
|
96
97
|
}
|
|
97
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
|
+
|
|
98
110
|
/** 标题:Codex 不写标题字段,用首条真人用户消息(rollout 头几十行里就有) */
|
|
99
111
|
function titleFrom(records: Record<string, unknown>[]): string | undefined {
|
|
100
112
|
for (const record of records) {
|
|
@@ -113,10 +125,22 @@ export const codexSource: ImportSourceModule = {
|
|
|
113
125
|
|
|
114
126
|
async scan(options?: ScanOptions): Promise<ExternalSessionSummary[]> {
|
|
115
127
|
const limit = options?.limit ?? 0;
|
|
116
|
-
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
|
+
|
|
117
141
|
const out: ExternalSessionSummary[] = [];
|
|
118
|
-
for (const { file, mtime } of candidates) {
|
|
119
|
-
if (reachedLimit(out.length, limit)) break;
|
|
142
|
+
for (const { file, mtime, updatedAt } of candidates) {
|
|
143
|
+
if (reachedLimit(out.length, limit)) break;
|
|
120
144
|
let head: string;
|
|
121
145
|
try {
|
|
122
146
|
head = await readHead(file);
|
|
@@ -136,7 +160,7 @@ export const codexSource: ImportSourceModule = {
|
|
|
136
160
|
model: meta.model,
|
|
137
161
|
provider: meta.provider,
|
|
138
162
|
createdAt: meta.createdAt ?? mtime,
|
|
139
|
-
updatedAt
|
|
163
|
+
updatedAt,
|
|
140
164
|
location: file,
|
|
141
165
|
});
|
|
142
166
|
}
|
|
@@ -154,11 +178,18 @@ export const codexSource: ImportSourceModule = {
|
|
|
154
178
|
const skipped: string[] = [];
|
|
155
179
|
let synthetic = 0;
|
|
156
180
|
let reasoningSkipped = 0;
|
|
181
|
+
let runtimeContext = 0;
|
|
157
182
|
let meta: Meta = { cwd: summary.projectPath, createdAt: summary.createdAt, model: summary.model, provider: summary.provider };
|
|
158
183
|
|
|
159
184
|
for (const record of eachJsonLine(text)) {
|
|
160
185
|
if (record.type === "session_meta") {
|
|
161
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;
|
|
162
193
|
continue;
|
|
163
194
|
}
|
|
164
195
|
if (record.type !== "response_item") continue;
|
|
@@ -169,11 +200,15 @@ export const codexSource: ImportSourceModule = {
|
|
|
169
200
|
switch (payload.type) {
|
|
170
201
|
case "message": {
|
|
171
202
|
const role = payload.role;
|
|
172
|
-
if (role === "developer")
|
|
203
|
+
if (role === "developer") {
|
|
204
|
+
runtimeContext += 1; // 系统/开发者上下文由 pi 按当前工作区重新生成
|
|
205
|
+
continue;
|
|
206
|
+
}
|
|
173
207
|
const blocks = messageBlocks(payload);
|
|
174
208
|
const plain = blocks.filter((block) => block.type === "text").map((block) => block.text).join("\n").trim();
|
|
175
209
|
if (role === "user") {
|
|
176
|
-
|
|
210
|
+
// 只有图片的真人输入也必须保留;纯文本的环境/AGENTS 注入仍不冒充用户消息
|
|
211
|
+
if (!blocks.length || (plain && isSynthetic(plain))) {
|
|
177
212
|
synthetic += 1;
|
|
178
213
|
continue;
|
|
179
214
|
}
|
|
@@ -217,6 +252,7 @@ export const codexSource: ImportSourceModule = {
|
|
|
217
252
|
|
|
218
253
|
if (synthetic) skipped.push(`跳过 ${synthetic} 条合成输入`);
|
|
219
254
|
if (reasoningSkipped) skipped.push(`跳过 ${reasoningSkipped} 条仅含加密内容的推理记录`);
|
|
255
|
+
if (runtimeContext) skipped.push(`跳过 ${runtimeContext} 条源工具运行时上下文(pi 会按当前工作区重新生成)`);
|
|
220
256
|
if (!entries.some((entry) => entry.type === "message")) return null;
|
|
221
257
|
return { summary: { ...summary, ...meta, projectPath: meta.cwd, model: meta.model, provider: meta.provider }, entries, skipped };
|
|
222
258
|
},
|
|
@@ -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,41 +46,69 @@ 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);
|
|
82
111
|
// 子代理会话(grok 的 spawn_subagent:subagent / subagent_fork / subagent_resume,本机 24 条)
|
|
83
|
-
// 以及它们的派生:agent_name 是 general-purpose / explore 之类,不是你在用的那条对话
|
|
84
112
|
if (typeof summary.session_kind === "string" && summary.session_kind.startsWith("subagent")) continue;
|
|
85
113
|
if (numMessages === 0) continue; // 开了没用的空会话
|
|
86
114
|
// 只有系统提示与合成提醒(system_reminder)的会话读不出对话,别列进列表
|
|
@@ -92,7 +120,7 @@ export const grokSource: ImportSourceModule = {
|
|
|
92
120
|
projectPath: typeof info?.cwd === "string" ? info.cwd : decodeProjectDir(path.basename(path.dirname(dir))),
|
|
93
121
|
model: typeof summary.current_model_id === "string" ? summary.current_model_id : undefined,
|
|
94
122
|
createdAt: toEpochMs(summary.created_at) ?? mtime,
|
|
95
|
-
updatedAt
|
|
123
|
+
updatedAt,
|
|
96
124
|
...(Number.isFinite(numMessages) ? { messageCount: numMessages } : {}),
|
|
97
125
|
location: history,
|
|
98
126
|
});
|
|
@@ -111,17 +139,18 @@ export const grokSource: ImportSourceModule = {
|
|
|
111
139
|
const skipped: string[] = [];
|
|
112
140
|
let encryptedReasoning = 0;
|
|
113
141
|
let synthetic = 0;
|
|
142
|
+
let runtimeContext = 0;
|
|
114
143
|
let model = summary.model;
|
|
115
144
|
|
|
116
145
|
for (const record of eachJsonLine(text)) {
|
|
117
146
|
const timestamp = toEpochMs(record.timestamp ?? record.time ?? record.created_at) ?? summary.createdAt ?? Date.now();
|
|
118
147
|
switch (record.type) {
|
|
119
148
|
case "system":
|
|
120
|
-
|
|
149
|
+
runtimeContext += 1;
|
|
150
|
+
continue; // 系统提示由 pi 按目标工作区重新生成,不冒充用户消息
|
|
121
151
|
case "user": {
|
|
122
152
|
const blocks = userBlocks(record.content);
|
|
123
|
-
|
|
124
|
-
if (record.synthetic_reason || !plain) {
|
|
153
|
+
if (record.synthetic_reason || !blocks.length) {
|
|
125
154
|
synthetic += 1;
|
|
126
155
|
continue;
|
|
127
156
|
}
|
|
@@ -145,8 +174,8 @@ export const grokSource: ImportSourceModule = {
|
|
|
145
174
|
continue;
|
|
146
175
|
}
|
|
147
176
|
case "reasoning": {
|
|
148
|
-
const text =
|
|
149
|
-
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 } : {}) } });
|
|
150
179
|
else encryptedReasoning += 1; // 加密推理无正文可导:计数后汇总成一行,不要刷屏
|
|
151
180
|
continue;
|
|
152
181
|
}
|
|
@@ -163,6 +192,7 @@ export const grokSource: ImportSourceModule = {
|
|
|
163
192
|
|
|
164
193
|
if (synthetic) skipped.push(`跳过 ${synthetic} 条合成输入`);
|
|
165
194
|
if (encryptedReasoning) skipped.push(`跳过 ${encryptedReasoning} 条仅含加密内容的推理记录`);
|
|
195
|
+
if (runtimeContext) skipped.push(`跳过 ${runtimeContext} 条源工具运行时上下文(pi 会按当前工作区重新生成)`);
|
|
166
196
|
if (!entries.some((entry) => entry.type === "message")) return null;
|
|
167
197
|
return { summary: { ...summary, model }, entries, skipped };
|
|
168
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)]);
|