@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.
Files changed (103) hide show
  1. package/.next/BUILD_ID +1 -1
  2. package/.next/app-path-routes-manifest.json +6 -6
  3. package/.next/build-manifest.json +3 -3
  4. package/.next/prerender-manifest.json +3 -3
  5. package/.next/react-loadable-manifest.json +1 -1
  6. package/.next/required-server-files.js +3 -1
  7. package/.next/required-server-files.json +3 -1
  8. package/.next/server/app/_global-error/page_client-reference-manifest.js +1 -1
  9. package/.next/server/app/_global-error.html +1 -1
  10. package/.next/server/app/_global-error.rsc +1 -1
  11. package/.next/server/app/_global-error.segments/_full.segment.rsc +1 -1
  12. package/.next/server/app/_global-error.segments/_global-error/__PAGE__.segment.rsc +1 -1
  13. package/.next/server/app/_global-error.segments/_tree.segment.rsc +1 -1
  14. package/.next/server/app/_not-found/page_client-reference-manifest.js +1 -1
  15. package/.next/server/app/_not-found.html +1 -1
  16. package/.next/server/app/_not-found.rsc +1 -1
  17. package/.next/server/app/_not-found.segments/_full.segment.rsc +1 -1
  18. package/.next/server/app/_not-found.segments/_not-found/__PAGE__.segment.rsc +1 -1
  19. package/.next/server/app/_not-found.segments/_tree.segment.rsc +1 -1
  20. package/.next/server/app/api/agent/[id]/events/route.js.nft.json +1 -1
  21. package/.next/server/app/api/agent/[id]/route.js.nft.json +1 -1
  22. package/.next/server/app/api/agent/[id]/tree/route.js.nft.json +1 -1
  23. package/.next/server/app/api/agent/new/route.js.nft.json +1 -1
  24. package/.next/server/app/api/growth/route.js.nft.json +1 -1
  25. package/.next/server/app/api/models/route.js.nft.json +1 -1
  26. package/.next/server/app/api/plugins/route.js.nft.json +1 -1
  27. package/.next/server/app/api/prompts/route.js.nft.json +1 -1
  28. package/.next/server/app/api/security/route.js.nft.json +1 -1
  29. package/.next/server/app/api/sessions/[id]/export/route.js.nft.json +1 -1
  30. package/.next/server/app/api/sessions/[id]/route.js.nft.json +1 -1
  31. package/.next/server/app/api/sessions/import/route.js +3 -3
  32. package/.next/server/app/api/sessions/route.js.nft.json +1 -1
  33. package/.next/server/app/api/skills/route.js +2 -2
  34. package/.next/server/app/api/skills/route.js.nft.json +1 -1
  35. package/.next/server/app/api/update/route.js +1 -1
  36. package/.next/server/app/api/update/route.js.nft.json +1 -1
  37. package/.next/server/app/api/version/route.js +1 -1
  38. package/.next/server/app/api/workspaces/route.js.nft.json +1 -1
  39. package/.next/server/app/index.html +1 -1
  40. package/.next/server/app/index.rsc +3 -3
  41. package/.next/server/app/index.segments/__PAGE__.segment.rsc +3 -3
  42. package/.next/server/app/index.segments/_full.segment.rsc +3 -3
  43. package/.next/server/app/index.segments/_tree.segment.rsc +1 -1
  44. package/.next/server/app/login/page_client-reference-manifest.js +1 -1
  45. package/.next/server/app/login.html +1 -1
  46. package/.next/server/app/login.rsc +2 -2
  47. package/.next/server/app/login.segments/_full.segment.rsc +2 -2
  48. package/.next/server/app/login.segments/_tree.segment.rsc +1 -1
  49. package/.next/server/app/login.segments/login/__PAGE__.segment.rsc +2 -2
  50. package/.next/server/app/page_client-reference-manifest.js +1 -1
  51. package/.next/server/app-paths-manifest.json +6 -6
  52. package/.next/server/chunks/496.js +1 -1
  53. package/.next/server/chunks/686.js +1 -1
  54. package/.next/server/middleware-build-manifest.js +1 -1
  55. package/.next/server/middleware-react-loadable-manifest.js +1 -1
  56. package/.next/server/pages/404.html +1 -1
  57. package/.next/server/pages/500.html +1 -1
  58. package/.next/server/server-reference-manifest.json +1 -1
  59. package/.next/static/chunks/4632.0ed92ae42905ce40.js +1 -0
  60. package/.next/static/chunks/4686-7d694def920ff093.js +1 -0
  61. package/.next/static/chunks/{webpack-b1a572e319353f5d.js → webpack-42c5aae79b0e5d8c.js} +1 -1
  62. package/README.md +52 -8
  63. package/assets/archive.png +0 -0
  64. package/assets/growth-1.png +0 -0
  65. package/assets/growth-2.png +0 -0
  66. package/assets/import.png +0 -0
  67. package/assets/main.png +0 -0
  68. package/assets/outline.png +0 -0
  69. package/assets/prompts-1.png +0 -0
  70. package/assets/prompts-2.png +0 -0
  71. package/assets/providers-1.png +0 -0
  72. package/assets/providers-2.png +0 -0
  73. package/assets/providers-3.png +0 -0
  74. package/assets/trace-1.png +0 -0
  75. package/assets/trace-2.png +0 -0
  76. package/assets/trace-3.png +0 -0
  77. package/assets/trace-4.png +0 -0
  78. package/assets/viewer-1.png +0 -0
  79. package/assets/viewer-2.png +0 -0
  80. package/next.config.ts +6 -2
  81. package/package.json +7 -3
  82. package/scripts/launcher.mjs +1 -1
  83. package/scripts/managed-niubash-assets.mjs +146 -0
  84. package/scripts/prepare-niubash-packages.mjs +182 -0
  85. package/scripts/smoke-managed-niubash.mjs +62 -0
  86. package/scripts/verify-package.mjs +53 -0
  87. package/src/components/SessionImportSection.tsx +76 -23
  88. package/src/i18n.tsx +2 -0
  89. package/src/lib/agent-manager.ts +3 -0
  90. package/src/lib/managed-niubash.ts +227 -0
  91. package/src/lib/session-import/claude.ts +23 -2
  92. package/src/lib/session-import/codex.ts +61 -8
  93. package/src/lib/session-import/dsh.ts +12 -1
  94. package/src/lib/session-import/files.ts +14 -0
  95. package/src/lib/session-import/grok.ts +56 -23
  96. package/src/lib/session-import/types.ts +12 -0
  97. package/src/lib/skills-service.ts +51 -14
  98. package/.next/static/chunks/4632.d574c210ad78d065.js +0 -1
  99. package/.next/static/chunks/4686-0a4155f8488314b8.js +0 -1
  100. package/assets/showcase.png +0 -0
  101. package/bin/pi.js +0 -50
  102. /package/.next/static/{olV7jxKRN5VhfEK012WhV → DdBwPYoHBKtaJ97QIr6Co}/_buildManifest.js +0 -0
  103. /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, sortByRecency } from "./files";
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 candidates = await sortByRecency(await listFiles(ROOT(), { match: (name) => name.startsWith("rollout-") && name.endsWith(".jsonl") }));
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: mtime,
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") continue; // 系统提示,不进对话
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
- if (!plain || isSynthetic(plain)) {
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 = { id: typeof parsed.id === "string" ? parsed.id : undefined, cwd: typeof parsed.cwd === "string" ? parsed.cwd : undefined, createdAt: toEpochMs(parsed.createdAt) };
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, sortByRecency } from "./files";
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" && typeof record.summary === "string" && record.summary.trim()) return true;
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 可能是字符串或块数组 */function userBlocks(content: unknown): ImportedBlock[] {
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
- const candidates = await sortByRecency(files.map((file) => path.join(path.dirname(file), "chat_history.jsonl")));
69
- const out: ExternalSessionSummary[] = [];
70
- for (const { file: history, mtime } of candidates) {
71
- if (reachedLimit(out.length, limit)) break; // 只读最近的若干条
72
- const dir = path.dirname(history);
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(path.join(dir, "summary.json"), "utf8")) as Record<string, unknown>;
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
- continue;
100
+ return null;
78
101
  }
79
- if (mtime === undefined) continue; // 没有正文就没什么可导
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
- const numMessages = Number(summary.num_messages ?? summary.num_chat_messages);
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: toEpochMs(summary.updated_at ?? summary.last_active_at) ?? mtime,
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
- continue; // 系统提示不进对话
149
+ runtimeContext += 1;
150
+ continue; // 系统提示由 pi 按目标工作区重新生成,不冒充用户消息
118
151
  case "user": {
119
152
  const blocks = userBlocks(record.content);
120
- const plain = blocks.map((block) => (block.type === "text" ? block.text : "")).join("\n").trim();
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 = typeof record.summary === "string" ? record.summary : "";
146
- if (text.trim()) entries.push({ type: "message", message: { role: "assistant", content: [{ type: "thinking", thinking: text }], timestamp, ...(model ? { model } : {}) } });
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
- function classifyScope(filePath: string, cwd?: string): SkillView["scope"] {
22
- const norm = filePath.replace(/\\/g, "/").toLowerCase();
23
- const agentDir = getAgentDir().replace(/\\/g, "/").toLowerCase();
24
- const home = os.homedir().replace(/\\/g, "/").toLowerCase();
25
- if (norm.startsWith(`${agentDir}/`) || norm.startsWith(`${home}/.agents/`) || norm.startsWith(`${home}/.pi/`))
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.startsWith(path.resolve(cwd).replace(/\\/g, "/").toLowerCase())) return "project";
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, dir),
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 scope = classifyScope(authorized, cwd);
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
- if (classifyScope(authorized, cwd) === "package") {
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 agentDir = getAgentDir().replace(/\\/g, "/");
109
- const normalizedSkillDir = skillDir.replace(/\\/g, "/");
110
- const knownRoots = [`${agentDir}/skills`, `${agentDir}/web-skills`];
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, cwd);
154
+ const scope = await classifyScope(authorized, context);
118
155
  await reloadSessionsForCwd(scope === "project" ? cwd : undefined);
119
156
  return { removed: skillDir };
120
157
  }