@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
|
@@ -36,6 +36,8 @@ interface ImportOutcome {
|
|
|
36
36
|
reason?: string;
|
|
37
37
|
messageCount?: number;
|
|
38
38
|
workspace?: string;
|
|
39
|
+
/** 已成功导入,但源里的运行时注入/加密内容等无法作为对话还原 */
|
|
40
|
+
skipped?: string[];
|
|
39
41
|
}
|
|
40
42
|
|
|
41
43
|
interface ImportReport {
|
|
@@ -60,6 +62,8 @@ function dayOf(ms?: number): string {
|
|
|
60
62
|
return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}`;
|
|
61
63
|
}
|
|
62
64
|
|
|
65
|
+
const summaryKey = (summary: Pick<Summary, "source" | "externalId">) => `${summary.source}:${summary.externalId}`;
|
|
66
|
+
|
|
63
67
|
export function SessionImportSection({ cwd }: { cwd: string }) {
|
|
64
68
|
const { t } = useI18n();
|
|
65
69
|
const [summaries, setSummaries] = useState<Summary[]>([]);
|
|
@@ -83,9 +87,14 @@ export function SessionImportSection({ cwd }: { cwd: string }) {
|
|
|
83
87
|
setFailure(json.error || t.toastError);
|
|
84
88
|
return;
|
|
85
89
|
}
|
|
86
|
-
|
|
90
|
+
const nextSummaries = (json.data.sessions ?? []) as Summary[];
|
|
91
|
+
const nextImported = new Set<string>(json.data.imported ?? []);
|
|
92
|
+
const selectable = new Set(nextSummaries.map(summaryKey));
|
|
93
|
+
setSummaries(nextSummaries);
|
|
87
94
|
setErrors(json.data.errors ?? []);
|
|
88
|
-
setImportedKeys(
|
|
95
|
+
setImportedKeys(nextImported);
|
|
96
|
+
// 重新扫描后,源会话可能已经消失或被另一个请求导入;不要留下幽灵选择
|
|
97
|
+
setSelected((previous) => new Set([...previous].filter((item) => selectable.has(item) && !nextImported.has(item))));
|
|
89
98
|
} catch (error) {
|
|
90
99
|
setFailure(error instanceof Error ? error.message : String(error));
|
|
91
100
|
} finally {
|
|
@@ -109,8 +118,6 @@ export function SessionImportSection({ cwd }: { cwd: string }) {
|
|
|
109
118
|
void scan();
|
|
110
119
|
}, [scan]);
|
|
111
120
|
|
|
112
|
-
const key = (summary: Summary) => `${summary.source}:${summary.externalId}`;
|
|
113
|
-
|
|
114
121
|
const groups = useMemo(() => {
|
|
115
122
|
const bySource = new Map<string, Summary[]>();
|
|
116
123
|
for (const summary of summaries) {
|
|
@@ -120,16 +127,30 @@ export function SessionImportSection({ cwd }: { cwd: string }) {
|
|
|
120
127
|
}
|
|
121
128
|
return SOURCE_ORDER.filter((source) => bySource.has(source)).map((source) => {
|
|
122
129
|
const rows = [...(bySource.get(source) as Summary[])].sort((left, right) => (right.updatedAt ?? right.createdAt ?? 0) - (left.updatedAt ?? left.createdAt ?? 0));
|
|
123
|
-
return { source, rows, imported: rows.filter((row) => importedKeys.has(
|
|
130
|
+
return { source, rows, imported: rows.filter((row) => importedKeys.has(summaryKey(row))).length };
|
|
124
131
|
});
|
|
125
132
|
}, [summaries, importedKeys]);
|
|
126
133
|
|
|
134
|
+
/**
|
|
135
|
+
* 一次只显示一个来源:六个工具各列 15 条就是 90 行,全铺出来既难找也没必要。
|
|
136
|
+
* 首次打开默认落到“最近活动会话”所在的来源;用户切换后保持选择。
|
|
137
|
+
* 直接派生有效来源,不用 effect 回填,避免初始化 effect 覆盖用户刚完成的点击。
|
|
138
|
+
*/
|
|
139
|
+
const [activeSource, setActiveSource] = useState("");
|
|
140
|
+
const defaultSource = groups.reduce<{ source: string; at: number } | null>((latest, group) => {
|
|
141
|
+
const first = group.rows[0];
|
|
142
|
+
const at = first?.updatedAt ?? first?.createdAt ?? 0;
|
|
143
|
+
return !latest || at > latest.at ? { source: group.source, at } : latest;
|
|
144
|
+
}, null)?.source ?? "";
|
|
145
|
+
const visibleSource = activeSource && groups.some((group) => group.source === activeSource) ? activeSource : defaultSource;
|
|
146
|
+
const activeGroup = groups.find((group) => group.source === visibleSource);
|
|
147
|
+
|
|
127
148
|
const toggle = (summary: Summary) => {
|
|
128
|
-
if (importedKeys.has(
|
|
149
|
+
if (importedKeys.has(summaryKey(summary))) return;
|
|
129
150
|
setSelected((previous) => {
|
|
130
151
|
const next = new Set(previous);
|
|
131
|
-
if (next.has(
|
|
132
|
-
else next.add(
|
|
152
|
+
if (next.has(summaryKey(summary))) next.delete(summaryKey(summary));
|
|
153
|
+
else next.add(summaryKey(summary));
|
|
133
154
|
return next;
|
|
134
155
|
});
|
|
135
156
|
};
|
|
@@ -138,9 +159,9 @@ export function SessionImportSection({ cwd }: { cwd: string }) {
|
|
|
138
159
|
setSelected((previous) => {
|
|
139
160
|
const next = new Set(previous);
|
|
140
161
|
for (const row of rows) {
|
|
141
|
-
if (importedKeys.has(
|
|
142
|
-
if (checked) next.add(
|
|
143
|
-
else next.delete(
|
|
162
|
+
if (importedKeys.has(summaryKey(row))) continue;
|
|
163
|
+
if (checked) next.add(summaryKey(row));
|
|
164
|
+
else next.delete(summaryKey(row));
|
|
144
165
|
}
|
|
145
166
|
return next;
|
|
146
167
|
});
|
|
@@ -148,7 +169,7 @@ export function SessionImportSection({ cwd }: { cwd: string }) {
|
|
|
148
169
|
|
|
149
170
|
const run = async () => {
|
|
150
171
|
if (!selected.size) return;
|
|
151
|
-
const items = summaries.filter((summary) => selected.has(
|
|
172
|
+
const items = summaries.filter((summary) => selected.has(summaryKey(summary))).map((summary) => ({ source: summary.source, externalId: summary.externalId }));
|
|
152
173
|
setImporting(true);
|
|
153
174
|
setFailure(null);
|
|
154
175
|
setReport(null);
|
|
@@ -187,6 +208,7 @@ export function SessionImportSection({ cwd }: { cwd: string }) {
|
|
|
187
208
|
|
|
188
209
|
const failures = report?.results.filter((result) => result.status === "failed") ?? [];
|
|
189
210
|
const skipped = report?.results.filter((result) => result.status === "skipped") ?? [];
|
|
211
|
+
const importedWithNotes = report?.results.filter((result) => result.status === "imported" && result.skipped?.length) ?? [];
|
|
190
212
|
|
|
191
213
|
return (
|
|
192
214
|
<div className="flex flex-col gap-4" data-testid="session-import">
|
|
@@ -266,6 +288,11 @@ export function SessionImportSection({ cwd }: { cwd: string }) {
|
|
|
266
288
|
{sourceLabel(result.source)} · {result.title || result.externalId} — {result.reason}
|
|
267
289
|
</div>
|
|
268
290
|
))}
|
|
291
|
+
{importedWithNotes.map((result) => (
|
|
292
|
+
<div key={`note-${result.source}-${result.externalId}`} className="mt-1" style={{ fontSize: 11.5, color: "var(--dsw-label-caption)" }}>
|
|
293
|
+
{sourceLabel(result.source)} · {result.title || result.externalId} — {result.skipped?.join(";")}
|
|
294
|
+
</div>
|
|
295
|
+
))}
|
|
269
296
|
{failures.map((result) => (
|
|
270
297
|
<div key={`fail-${result.source}-${result.externalId}`} className="mt-1" style={{ fontSize: 11.5, color: "var(--dsw-danger)" }}>
|
|
271
298
|
{sourceLabel(result.source)} · {result.title || result.externalId} — {result.reason}
|
|
@@ -276,26 +303,52 @@ export function SessionImportSection({ cwd }: { cwd: string }) {
|
|
|
276
303
|
|
|
277
304
|
{!scanning && !summaries.length && !errors.length && <div style={{ fontSize: 13, color: "var(--dsw-label-caption)" }}>{t.importEmpty}</div>}
|
|
278
305
|
|
|
279
|
-
{
|
|
280
|
-
|
|
306
|
+
{/* 来源选择器:选中哪个就只列哪个工具的会话 */}
|
|
307
|
+
{groups.length > 0 && (
|
|
308
|
+
<div className="flex flex-wrap items-center gap-1.5" role="tablist" aria-label={t.importPickSource} data-testid="import-source-tabs">
|
|
309
|
+
{groups.map((group) => {
|
|
310
|
+
const active = group.source === visibleSource;
|
|
311
|
+
return (
|
|
312
|
+
<button
|
|
313
|
+
key={group.source}
|
|
314
|
+
type="button"
|
|
315
|
+
role="tab"
|
|
316
|
+
aria-selected={active}
|
|
317
|
+
className="rounded-full px-3 py-1 transition-colors"
|
|
318
|
+
style={{
|
|
319
|
+
fontSize: 12,
|
|
320
|
+
border: `0.5px solid ${active ? "var(--dsw-accent)" : "var(--dsw-border-l2)"}`,
|
|
321
|
+
background: active ? "var(--dsw-accent)" : "transparent",
|
|
322
|
+
color: active ? "#fff" : "var(--dsw-label-tertiary)",
|
|
323
|
+
}}
|
|
324
|
+
onClick={() => setActiveSource(group.source)}
|
|
325
|
+
data-testid={`import-source-${group.source}`}
|
|
326
|
+
>
|
|
327
|
+
{sourceLabel(group.source)}
|
|
328
|
+
<span style={{ marginLeft: 6, opacity: 0.75 }}>{group.rows.length}</span>
|
|
329
|
+
</button>
|
|
330
|
+
);
|
|
331
|
+
})}
|
|
332
|
+
</div>
|
|
333
|
+
)}
|
|
334
|
+
|
|
335
|
+
{activeGroup && (
|
|
336
|
+
<div className="flex flex-col gap-2" data-testid={`import-group-${activeGroup.source}`}>
|
|
281
337
|
<div className="flex items-center gap-2">
|
|
282
|
-
<span className="rounded-full px-2 py-0.5" style={{ fontSize: 10.5, background: "var(--dsw-hover)", color: "var(--dsw-label-tertiary)" }}>
|
|
283
|
-
{sourceLabel(group.source)}
|
|
284
|
-
</span>
|
|
285
338
|
<span style={{ fontSize: 11.5, color: "var(--dsw-label-caption)" }}>
|
|
286
|
-
{t.importGroupCount.replace("{n}", String(
|
|
339
|
+
{t.importGroupCount.replace("{n}", String(activeGroup.rows.length)).replace("{imported}", String(activeGroup.imported))}
|
|
287
340
|
</span>
|
|
288
341
|
<div className="flex-1" />
|
|
289
|
-
<button className="rounded-lg px-2 py-0.5" style={{ fontSize: 11.5, color: "var(--dsw-label-tertiary)" }} onClick={() => selectGroup(
|
|
342
|
+
<button className="rounded-lg px-2 py-0.5" style={{ fontSize: 11.5, color: "var(--dsw-label-tertiary)" }} onClick={() => selectGroup(activeGroup.rows, true)}>
|
|
290
343
|
{t.importSelectAll}
|
|
291
344
|
</button>
|
|
292
|
-
<button className="rounded-lg px-2 py-0.5" style={{ fontSize: 11.5, color: "var(--dsw-label-caption)" }} onClick={() => selectGroup(
|
|
345
|
+
<button className="rounded-lg px-2 py-0.5" style={{ fontSize: 11.5, color: "var(--dsw-label-caption)" }} onClick={() => selectGroup(activeGroup.rows, false)}>
|
|
293
346
|
{t.importSelectNone}
|
|
294
347
|
</button>
|
|
295
348
|
</div>
|
|
296
349
|
<div className="flex flex-col gap-1.5">
|
|
297
|
-
{
|
|
298
|
-
const rowKey =
|
|
350
|
+
{activeGroup.rows.map((row) => {
|
|
351
|
+
const rowKey = summaryKey(row);
|
|
299
352
|
const done = importedKeys.has(rowKey);
|
|
300
353
|
const checked = selected.has(rowKey);
|
|
301
354
|
return (
|
|
@@ -327,7 +380,7 @@ export function SessionImportSection({ cwd }: { cwd: string }) {
|
|
|
327
380
|
})}
|
|
328
381
|
</div>
|
|
329
382
|
</div>
|
|
330
|
-
)
|
|
383
|
+
)}
|
|
331
384
|
</div>
|
|
332
385
|
);
|
|
333
386
|
}
|
package/src/i18n.tsx
CHANGED
|
@@ -387,6 +387,7 @@ const zh = {
|
|
|
387
387
|
importSourceOpencode: "opencode",
|
|
388
388
|
importSourceError: "{source} 扫描失败:{message}",
|
|
389
389
|
importGroupCount: "{n} 条 · 已导入 {imported}",
|
|
390
|
+
importPickSource: "选择来源",
|
|
390
391
|
importLimitHint: "每个来源只列最近 {n} 条对话(更早的会话不扫描,省时省资源)",
|
|
391
392
|
importLimitHintTooltip: "看更早的会话:把 src/lib/session-import/index.ts 里的 SCAN_LIMIT_PER_SOURCE 调大",
|
|
392
393
|
importSelectAll: "全选本组",
|
|
@@ -985,6 +986,7 @@ const en: Dict = {
|
|
|
985
986
|
importSourceOpencode: "opencode",
|
|
986
987
|
importSourceError: "{source} scan failed: {message}",
|
|
987
988
|
importGroupCount: "{n} sessions · {imported} imported",
|
|
989
|
+
importPickSource: "Choose a source",
|
|
988
990
|
importLimitHint: "Only the {n} most recent conversations per source are scanned",
|
|
989
991
|
importLimitHintTooltip: "To see older sessions, raise SCAN_LIMIT_PER_SOURCE in src/lib/session-import/index.ts",
|
|
990
992
|
importSelectAll: "Select group",
|
package/src/lib/agent-manager.ts
CHANGED
|
@@ -35,6 +35,7 @@ import { sanitizeToolOutput } from "./text-sanitize";
|
|
|
35
35
|
import { BoundaryError } from "./path-security";
|
|
36
36
|
import { estimateTokensOf } from "./process-format";
|
|
37
37
|
import { getPiSettings } from "./pi-settings";
|
|
38
|
+
import { createManagedNiubashTools } from "./managed-niubash";
|
|
38
39
|
import { clampThinkingLevel, getSupportedThinkingLevels } from "@earendil-works/pi-ai";
|
|
39
40
|
import type {
|
|
40
41
|
AgentCommand,
|
|
@@ -799,6 +800,8 @@ export async function ensureSession(m: Managed): Promise<AgentSession> {
|
|
|
799
800
|
modelRuntime: await getModelRuntime(),
|
|
800
801
|
resourceLoader: loader,
|
|
801
802
|
settingsManager: getSettingsManager(m.cwd),
|
|
803
|
+
// Windows: 同名 bash 覆盖 SDK 内置工具,指向托管 niubash;其它平台返回 [],走 Pi 默认。
|
|
804
|
+
customTools: createManagedNiubashTools(m.cwd),
|
|
802
805
|
});
|
|
803
806
|
created = session;
|
|
804
807
|
if (m.disposed) throw new Error("session is disposed");
|
|
@@ -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
|
+
}
|
|
@@ -19,6 +19,15 @@ const SYNTHETIC_PREFIXES = ["<command-name>", "<command-message>", "<local-comma
|
|
|
19
19
|
|
|
20
20
|
const isSynthetic = (text: string) => SYNTHETIC_PREFIXES.some((prefix) => text.startsWith(prefix));
|
|
21
21
|
|
|
22
|
+
/**
|
|
23
|
+
* 子代理的转写不能当作会话:Claude Code 把它们放在
|
|
24
|
+
* `<项目>/<会话 uuid>/subagents/` 下(含更深的 workflows/wf_<id>/ 层,本机 236 个子代理文件 vs 70 个主会话)。
|
|
25
|
+
* 那是主代理派出去的活,不是你在用的那条对话。
|
|
26
|
+
* 这些文件里的记录全是 isSidechain,本来也会被 isMessageLine 挡掉,
|
|
27
|
+
* 这里再按路径显式排除一次:意图更清楚,也不怕以后格式变化。
|
|
28
|
+
*/
|
|
29
|
+
const isSubagentTranscript = (file: string) => file.split(/[/\\]/).includes("subagents");
|
|
30
|
+
|
|
22
31
|
function isMessageLine(record: Record<string, unknown>): boolean {
|
|
23
32
|
return (record.type === "user" || record.type === "assistant") && record.isSidechain !== true && Boolean(record.message);
|
|
24
33
|
}
|
|
@@ -115,6 +124,7 @@ export const claudeSource: ImportSourceModule = {
|
|
|
115
124
|
const out: ExternalSessionSummary[] = [];
|
|
116
125
|
for (const { file, mtime } of candidates) {
|
|
117
126
|
if (reachedLimit(out.length, limit)) break; // 只读最近的若干条:后面的文件根本不打开
|
|
127
|
+
if (isSubagentTranscript(file)) continue; // 主代理派给子代理的活不算会话
|
|
118
128
|
let head: string;
|
|
119
129
|
try {
|
|
120
130
|
head = await readHead(file);
|
|
@@ -150,6 +160,7 @@ export const claudeSource: ImportSourceModule = {
|
|
|
150
160
|
const skipped: string[] = [];
|
|
151
161
|
let sidechains = 0;
|
|
152
162
|
let synthetic = 0;
|
|
163
|
+
let runtimeContext = 0;
|
|
153
164
|
let projectPath = summary.projectPath;
|
|
154
165
|
let model = summary.model;
|
|
155
166
|
let provider: string | undefined;
|
|
@@ -159,6 +170,12 @@ export const claudeSource: ImportSourceModule = {
|
|
|
159
170
|
sidechains += 1;
|
|
160
171
|
continue;
|
|
161
172
|
}
|
|
173
|
+
// Claude 的 attachment/system 是技能列表、token 提醒、环境快照等运行时上下文;
|
|
174
|
+
// pi 会按目标工作区生成自己的版本,不能伪装成真人 user 消息,但要在报告中明确告知。
|
|
175
|
+
if (record.type === "attachment" || record.type === "system") {
|
|
176
|
+
runtimeContext += 1;
|
|
177
|
+
continue;
|
|
178
|
+
}
|
|
162
179
|
if (typeof record.cwd === "string" && record.cwd.trim()) projectPath = projectPath ?? record.cwd;
|
|
163
180
|
const message = record.message as Record<string, unknown> | undefined;
|
|
164
181
|
if (!message) continue;
|
|
@@ -196,9 +213,12 @@ export const claudeSource: ImportSourceModule = {
|
|
|
196
213
|
if (record.type === "user") {
|
|
197
214
|
const { text: textBlocks, results } = splitUserContent((message as { content?: unknown }).content);
|
|
198
215
|
const plain = textBlocks.filter((block) => block.type === "text").map((block) => block.text).join("\n");
|
|
199
|
-
const
|
|
216
|
+
const nonText = textBlocks.filter((block) => block.type !== "text");
|
|
200
217
|
if (plain && !isSynthetic(plain.trim()) && !/^Caveat:/i.test(plain.trim())) {
|
|
201
|
-
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 });
|
|
202
222
|
} else if (plain) {
|
|
203
223
|
synthetic += 1;
|
|
204
224
|
}
|
|
@@ -213,6 +233,7 @@ export const claudeSource: ImportSourceModule = {
|
|
|
213
233
|
|
|
214
234
|
if (sidechains) skipped.push(`跳过 ${sidechains} 条侧链(子代理)记录`);
|
|
215
235
|
if (synthetic) skipped.push(`跳过 ${synthetic} 条合成输入(命令回显/系统提醒)`);
|
|
236
|
+
if (runtimeContext) skipped.push(`跳过 ${runtimeContext} 条源工具运行时上下文(pi 会按当前工作区重新生成)`);
|
|
216
237
|
if (!entries.some((entry) => entry.type === "message")) return null;
|
|
217
238
|
return { summary: { ...summary, projectPath, model, provider }, entries, skipped };
|
|
218
239
|
},
|