@rexvane/piweb 0.3.12 → 0.3.14

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 (177) hide show
  1. package/.next/BUILD_ID +1 -1
  2. package/.next/app-path-routes-manifest.json +8 -7
  3. package/.next/build-manifest.json +3 -3
  4. package/.next/prerender-manifest.json +3 -3
  5. package/.next/react-loadable-manifest.json +2 -2
  6. package/.next/routes-manifest.json +6 -0
  7. package/.next/server/app/_global-error/page.js +2 -2
  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.js +1 -1
  15. package/.next/server/app/_not-found/page_client-reference-manifest.js +1 -1
  16. package/.next/server/app/_not-found.html +1 -1
  17. package/.next/server/app/_not-found.rsc +1 -1
  18. package/.next/server/app/_not-found.segments/_full.segment.rsc +1 -1
  19. package/.next/server/app/_not-found.segments/_not-found/__PAGE__.segment.rsc +1 -1
  20. package/.next/server/app/_not-found.segments/_tree.segment.rsc +1 -1
  21. package/.next/server/app/api/agent/[id]/events/route.js +2 -2
  22. package/.next/server/app/api/agent/[id]/events/route.js.nft.json +1 -1
  23. package/.next/server/app/api/agent/[id]/route.js +1 -1
  24. package/.next/server/app/api/agent/[id]/route.js.nft.json +1 -1
  25. package/.next/server/app/api/agent/[id]/tree/route.js +1 -1
  26. package/.next/server/app/api/agent/[id]/tree/route.js.nft.json +1 -1
  27. package/.next/server/app/api/agent/new/route.js +1 -1
  28. package/.next/server/app/api/agent/new/route.js.nft.json +1 -1
  29. package/.next/server/app/api/config/route.js +1 -1
  30. package/.next/server/app/api/files/route.js +1 -1
  31. package/.next/server/app/api/git/route.js +2 -2
  32. package/.next/server/app/api/growth/route.js +1 -1
  33. package/.next/server/app/api/growth/route.js.nft.json +1 -1
  34. package/.next/server/app/api/health/route.js +1 -1
  35. package/.next/server/app/api/models/route.js +1 -1
  36. package/.next/server/app/api/models/route.js.nft.json +1 -1
  37. package/.next/server/app/api/pi-settings/route.js +1 -1
  38. package/.next/server/app/api/pi-settings/route.js.nft.json +1 -1
  39. package/.next/server/app/api/plugins/route.js +1 -1
  40. package/.next/server/app/api/plugins/route.js.nft.json +1 -1
  41. package/.next/server/app/api/prompts/route.js +1 -1
  42. package/.next/server/app/api/prompts/route.js.nft.json +1 -1
  43. package/.next/server/app/api/security/route.js +1 -1
  44. package/.next/server/app/api/security/route.js.nft.json +1 -1
  45. package/.next/server/app/api/sessions/[id]/export/route.js +1 -1
  46. package/.next/server/app/api/sessions/[id]/export/route.js.nft.json +1 -1
  47. package/.next/server/app/api/sessions/[id]/route.js +1 -1
  48. package/.next/server/app/api/sessions/[id]/route.js.nft.json +1 -1
  49. package/.next/server/app/api/sessions/import/route.js +6 -0
  50. package/.next/server/app/api/sessions/import/route.js.nft.json +1 -0
  51. package/.next/server/app/api/sessions/import/route_client-reference-manifest.js +1 -0
  52. package/.next/server/app/api/sessions/route.js +1 -1
  53. package/.next/server/app/api/sessions/route.js.nft.json +1 -1
  54. package/.next/server/app/api/skills/route.js +2 -2
  55. package/.next/server/app/api/skills/route.js.nft.json +1 -1
  56. package/.next/server/app/api/update/route.js +2 -2
  57. package/.next/server/app/api/update/route.js.nft.json +1 -1
  58. package/.next/server/app/api/version/route.js +1 -1
  59. package/.next/server/app/api/web-auth/route.js +1 -1
  60. package/.next/server/app/api/workspaces/route.js +1 -1
  61. package/.next/server/app/api/workspaces/route.js.nft.json +1 -1
  62. package/.next/server/app/icon.svg/route.js +1 -1
  63. package/.next/server/app/index.html +1 -1
  64. package/.next/server/app/index.rsc +3 -3
  65. package/.next/server/app/index.segments/__PAGE__.segment.rsc +3 -3
  66. package/.next/server/app/index.segments/_full.segment.rsc +3 -3
  67. package/.next/server/app/index.segments/_tree.segment.rsc +1 -1
  68. package/.next/server/app/login/page.js +1 -1
  69. package/.next/server/app/login/page_client-reference-manifest.js +1 -1
  70. package/.next/server/app/login.html +1 -1
  71. package/.next/server/app/login.rsc +2 -2
  72. package/.next/server/app/login.segments/_full.segment.rsc +2 -2
  73. package/.next/server/app/login.segments/_tree.segment.rsc +1 -1
  74. package/.next/server/app/login.segments/login/__PAGE__.segment.rsc +2 -2
  75. package/.next/server/app/page.js +1 -1
  76. package/.next/server/app/page_client-reference-manifest.js +1 -1
  77. package/.next/server/app-paths-manifest.json +8 -7
  78. package/.next/server/chunks/496.js +1 -1
  79. package/.next/server/chunks/532.js +1 -0
  80. package/.next/server/chunks/686.js +2 -2
  81. package/.next/server/middleware-build-manifest.js +1 -1
  82. package/.next/server/middleware-react-loadable-manifest.js +1 -1
  83. package/.next/server/pages/404.html +1 -1
  84. package/.next/server/pages/500.html +1 -1
  85. package/.next/server/server-reference-manifest.json +1 -1
  86. package/.next/static/LOFl9bqqMV2goBV8erESO/_buildManifest.js +1 -0
  87. package/.next/static/chunks/4632.112d2e893d48d188.js +1 -0
  88. package/.next/static/chunks/4686-7d694def920ff093.js +1 -0
  89. package/.next/static/chunks/app/_global-error/page-f77e9ede9808b18e.js +1 -0
  90. package/.next/static/chunks/app/api/agent/[id]/events/route-f77e9ede9808b18e.js +1 -0
  91. package/.next/static/chunks/app/api/agent/[id]/route-f77e9ede9808b18e.js +1 -0
  92. package/.next/static/chunks/app/api/agent/[id]/tree/route-f77e9ede9808b18e.js +1 -0
  93. package/.next/static/chunks/app/api/agent/new/route-f77e9ede9808b18e.js +1 -0
  94. package/.next/static/chunks/app/api/config/route-f77e9ede9808b18e.js +1 -0
  95. package/.next/static/chunks/app/api/files/route-f77e9ede9808b18e.js +1 -0
  96. package/.next/static/chunks/app/api/git/route-f77e9ede9808b18e.js +1 -0
  97. package/.next/static/chunks/app/api/growth/route-f77e9ede9808b18e.js +1 -0
  98. package/.next/static/chunks/app/api/health/route-f77e9ede9808b18e.js +1 -0
  99. package/.next/static/chunks/app/api/models/route-f77e9ede9808b18e.js +1 -0
  100. package/.next/static/chunks/app/api/pi-settings/route-f77e9ede9808b18e.js +1 -0
  101. package/.next/static/chunks/app/api/plugins/route-f77e9ede9808b18e.js +1 -0
  102. package/.next/static/chunks/app/api/prompts/route-f77e9ede9808b18e.js +1 -0
  103. package/.next/static/chunks/app/api/security/route-f77e9ede9808b18e.js +1 -0
  104. package/.next/static/chunks/app/api/sessions/[id]/export/route-f77e9ede9808b18e.js +1 -0
  105. package/.next/static/chunks/app/api/sessions/[id]/route-f77e9ede9808b18e.js +1 -0
  106. package/.next/static/chunks/app/api/sessions/import/route-f77e9ede9808b18e.js +1 -0
  107. package/.next/static/chunks/app/api/sessions/route-f77e9ede9808b18e.js +1 -0
  108. package/.next/static/chunks/app/api/skills/route-f77e9ede9808b18e.js +1 -0
  109. package/.next/static/chunks/app/api/update/route-f77e9ede9808b18e.js +1 -0
  110. package/.next/static/chunks/app/api/version/route-f77e9ede9808b18e.js +1 -0
  111. package/.next/static/chunks/app/api/web-auth/route-f77e9ede9808b18e.js +1 -0
  112. package/.next/static/chunks/app/api/workspaces/route-f77e9ede9808b18e.js +1 -0
  113. package/.next/static/chunks/app/page-9a5440047c5a415c.js +1 -0
  114. package/.next/static/chunks/next/dist/client/components/builtin/app-error-f77e9ede9808b18e.js +1 -0
  115. package/.next/static/chunks/next/dist/client/components/builtin/forbidden-f77e9ede9808b18e.js +1 -0
  116. package/.next/static/chunks/next/dist/client/components/builtin/not-found-f77e9ede9808b18e.js +1 -0
  117. package/.next/static/chunks/next/dist/client/components/builtin/unauthorized-f77e9ede9808b18e.js +1 -0
  118. package/.next/static/chunks/{webpack-05a9e85529a18b93.js → webpack-d28e132e1b8c100c.js} +1 -1
  119. package/.next/types/app/api/sessions/import/route.ts +353 -0
  120. package/.next/types/routes.d.ts +2 -1
  121. package/.next/types/validator.ts +9 -0
  122. package/README.md +6 -2
  123. package/package.json +1 -1
  124. package/src/app/api/sessions/import/route.ts +74 -0
  125. package/src/components/PromptPanel.tsx +33 -31
  126. package/src/components/SessionImportSection.tsx +370 -0
  127. package/src/components/SettingsPanel.tsx +4 -1
  128. package/src/i18n.tsx +68 -6
  129. package/src/lib/prompt-sources.ts +29 -1
  130. package/src/lib/session-import/claude.ts +229 -0
  131. package/src/lib/session-import/codex.ts +223 -0
  132. package/src/lib/session-import/dsh-zstd.ts +69 -0
  133. package/src/lib/session-import/dsh.ts +259 -0
  134. package/src/lib/session-import/files.ts +84 -0
  135. package/src/lib/session-import/grok.ts +169 -0
  136. package/src/lib/session-import/index.ts +229 -0
  137. package/src/lib/session-import/oc-family.ts +275 -0
  138. package/src/lib/session-import/opencode.ts +7 -0
  139. package/src/lib/session-import/paths.ts +37 -0
  140. package/src/lib/session-import/sqlite.ts +89 -0
  141. package/src/lib/session-import/types.ts +171 -0
  142. package/src/lib/session-import/writer.ts +259 -0
  143. package/src/lib/session-import/zcode.ts +8 -0
  144. package/src/lib/skills-service.ts +51 -14
  145. package/.next/server/chunks/118.js +0 -1
  146. package/.next/static/2kt2UiWjv_viXpNlfjvBT/_buildManifest.js +0 -1
  147. package/.next/static/chunks/4127.dff5c3b33c6bfd1b.js +0 -1
  148. package/.next/static/chunks/4686-5bbee099126af797.js +0 -1
  149. package/.next/static/chunks/app/_global-error/page-961c055baebb3872.js +0 -1
  150. package/.next/static/chunks/app/api/agent/[id]/events/route-961c055baebb3872.js +0 -1
  151. package/.next/static/chunks/app/api/agent/[id]/route-961c055baebb3872.js +0 -1
  152. package/.next/static/chunks/app/api/agent/[id]/tree/route-961c055baebb3872.js +0 -1
  153. package/.next/static/chunks/app/api/agent/new/route-961c055baebb3872.js +0 -1
  154. package/.next/static/chunks/app/api/config/route-961c055baebb3872.js +0 -1
  155. package/.next/static/chunks/app/api/files/route-961c055baebb3872.js +0 -1
  156. package/.next/static/chunks/app/api/git/route-961c055baebb3872.js +0 -1
  157. package/.next/static/chunks/app/api/growth/route-961c055baebb3872.js +0 -1
  158. package/.next/static/chunks/app/api/health/route-961c055baebb3872.js +0 -1
  159. package/.next/static/chunks/app/api/models/route-961c055baebb3872.js +0 -1
  160. package/.next/static/chunks/app/api/pi-settings/route-961c055baebb3872.js +0 -1
  161. package/.next/static/chunks/app/api/plugins/route-961c055baebb3872.js +0 -1
  162. package/.next/static/chunks/app/api/prompts/route-961c055baebb3872.js +0 -1
  163. package/.next/static/chunks/app/api/security/route-961c055baebb3872.js +0 -1
  164. package/.next/static/chunks/app/api/sessions/[id]/export/route-961c055baebb3872.js +0 -1
  165. package/.next/static/chunks/app/api/sessions/[id]/route-961c055baebb3872.js +0 -1
  166. package/.next/static/chunks/app/api/sessions/route-961c055baebb3872.js +0 -1
  167. package/.next/static/chunks/app/api/skills/route-961c055baebb3872.js +0 -1
  168. package/.next/static/chunks/app/api/update/route-961c055baebb3872.js +0 -1
  169. package/.next/static/chunks/app/api/version/route-961c055baebb3872.js +0 -1
  170. package/.next/static/chunks/app/api/web-auth/route-961c055baebb3872.js +0 -1
  171. package/.next/static/chunks/app/api/workspaces/route-961c055baebb3872.js +0 -1
  172. package/.next/static/chunks/app/page-00c8ebf40ec5eaca.js +0 -1
  173. package/.next/static/chunks/next/dist/client/components/builtin/app-error-961c055baebb3872.js +0 -1
  174. package/.next/static/chunks/next/dist/client/components/builtin/forbidden-961c055baebb3872.js +0 -1
  175. package/.next/static/chunks/next/dist/client/components/builtin/not-found-961c055baebb3872.js +0 -1
  176. package/.next/static/chunks/next/dist/client/components/builtin/unauthorized-961c055baebb3872.js +0 -1
  177. /package/.next/static/{2kt2UiWjv_viXpNlfjvBT → LOFl9bqqMV2goBV8erESO}/_ssgManifest.js +0 -0
@@ -0,0 +1,229 @@
1
+ /**
2
+ * 导入会话的门面:扫描 → 选择 → 写入 pi 会话。
3
+ *
4
+ * 两条刻意的设计:
5
+ * 1. **不从客户端拿路径**。前端只回传 `{source, externalId}`,服务端在导入时按来源重新扫描一次
6
+ * 把 summary 解出来(扫描很便宜:SQLite 6~42ms,文件类只读文件头)。这样路径遍历之类的输入
7
+ * 完全进不来,也不会因为前端缓存了过期数据而写错文件。
8
+ * 2. **单来源失败不影响其他来源**。某个工具没装 / 库损坏时,那一组空着并带错误原因,其余照常。
9
+ */
10
+ import path from "node:path";
11
+ import fs from "node:fs/promises";
12
+ import type { ExternalSessionSummary, ImportSource, ImportSourceModule } from "./types";
13
+ import { IMPORT_SOURCES } from "./types";
14
+ import { claudeSource } from "./claude";
15
+ import { codexSource } from "./codex";
16
+ import { grokSource } from "./grok";
17
+ import { dshSource } from "./dsh";
18
+ import { zcodeSource } from "./zcode";
19
+ import { opencodeSource } from "./opencode";
20
+ import { importedKey, listImportedKeys, findImported, writeImportedSession } from "./writer";
21
+
22
+ export const IMPORT_SOURCE_MODULES: Record<ImportSource, ImportSourceModule> = {
23
+ claude: claudeSource,
24
+ codex: codexSource,
25
+ grok: grokSource,
26
+ zcode: zcodeSource,
27
+ dsh: dshSource,
28
+ opencode: opencodeSource,
29
+ };
30
+
31
+ /** 单来源扫描超时:库被别的进程锁死时不让整个面板卡住 */
32
+ const SCAN_TIMEOUT_MS = 30_000;
33
+ /**
34
+ * 面板每个来源只列最近这么多条:本机六个来源合计上千个会话,全读一遍纯属浪费,
35
+ * 而用户真正要导的基本都是最近用的那几个。想看更早的会话时把这里调大即可。
36
+ */
37
+ export const SCAN_LIMIT_PER_SOURCE = 15;
38
+ /** 单会话上限:超过就别导了,内存和解压都吃不消(dsh 解压后能膨胀到几百 MB) */
39
+ const MAX_SOURCE_BYTES = 200 * 1024 * 1024;
40
+ const MAX_ENTRIES = 50_000;
41
+
42
+ export interface SourceScanError {
43
+ source: ImportSource;
44
+ message: string;
45
+ }
46
+
47
+ export interface ScanReport {
48
+ summaries: ExternalSessionSummary[];
49
+ errors: SourceScanError[];
50
+ /** 已导入过的 `source:externalId`,界面用来标"已导入"并禁止重复选 */
51
+ imported: string[];
52
+ }
53
+
54
+ function withTimeout<T>(promise: Promise<T>, ms: number, message: string): Promise<T> {
55
+ return new Promise<T>((resolve, reject) => {
56
+ const timer = setTimeout(() => reject(new Error(message)), ms);
57
+ promise.then(
58
+ (value) => {
59
+ clearTimeout(timer);
60
+ resolve(value);
61
+ },
62
+ (error) => {
63
+ clearTimeout(timer);
64
+ reject(error);
65
+ },
66
+ );
67
+ });
68
+ }
69
+
70
+ /** 扫描全部来源;返回的 summary 带 imported 标记,且已按时间倒序 */
71
+ export async function scanAllSources(agentDir: string, options: { sources?: ImportSource[]; limit?: number } = {}): Promise<ScanReport> {
72
+ const sources = options.sources ?? IMPORT_SOURCES;
73
+ const limit = options.limit ?? SCAN_LIMIT_PER_SOURCE;
74
+ const summaries: ExternalSessionSummary[] = [];
75
+ const errors: SourceScanError[] = [];
76
+ const imported = await listImportedKeys(agentDir);
77
+
78
+ await Promise.all(sources.map(async (source) => {
79
+ const module = IMPORT_SOURCE_MODULES[source];
80
+ if (!module) return;
81
+ try {
82
+ const found = await withTimeout(module.scan({ limit }), SCAN_TIMEOUT_MS, "扫描超时(30 秒)");
83
+ summaries.push(...found);
84
+ } catch (error) {
85
+ errors.push({ source, message: error instanceof Error ? error.message : String(error) });
86
+ }
87
+ }));
88
+
89
+ summaries.sort((left, right) => (right.updatedAt ?? right.createdAt ?? 0) - (left.updatedAt ?? left.createdAt ?? 0));
90
+ return { summaries, errors, imported: [...imported] };
91
+ }
92
+
93
+ export interface ImportSelection {
94
+ source: ImportSource;
95
+ externalId: string;
96
+ }
97
+
98
+ export interface ImportOutcome {
99
+ source: ImportSource;
100
+ externalId: string;
101
+ title?: string;
102
+ status: "imported" | "skipped" | "failed";
103
+ reason?: string;
104
+ messageCount?: number;
105
+ /** 导入到的 pi 会话文件(成功时) */
106
+ path?: string;
107
+ /** 落到的工具目录;源项目路径本机不存在时会与源项目不同 */
108
+ workspace?: string;
109
+ skipped?: string[];
110
+ }
111
+
112
+ export interface ImportReport {
113
+ imported: number;
114
+ skipped: number;
115
+ failed: number;
116
+ results: ImportOutcome[];
117
+ /** 本次涉及到的目录(调用方据此注册工作区) */
118
+ workspaces: string[];
119
+ }
120
+
121
+ export interface ImportOptions {
122
+ agentDir: string;
123
+ /** 源项目路径在本机不存在时的兜底工作区(必须已存在) */
124
+ fallbackCwd?: string;
125
+ now?: number;
126
+ onProgress?: (done: number, total: number) => void;
127
+ }
128
+
129
+ async function isDirectory(target: string | undefined): Promise<boolean> {
130
+ if (!target || !path.isAbsolute(target)) return false;
131
+ try {
132
+ return (await fs.stat(target)).isDirectory();
133
+ } catch {
134
+ return false;
135
+ }
136
+ }
137
+
138
+ /** 源文件大小闸门:`file|id` 形式的 SQLite 位置没有单文件概念,直接放行(读的是库里的行) */
139
+ async function oversize(location: string): Promise<boolean> {
140
+ if (location.includes("|")) return false;
141
+ try {
142
+ return (await fs.stat(location)).size > MAX_SOURCE_BYTES;
143
+ } catch {
144
+ return false;
145
+ }
146
+ }
147
+
148
+ export async function importSessions(items: ImportSelection[], options: ImportOptions): Promise<ImportReport> {
149
+ const results: ImportOutcome[] = [];
150
+ const workspaces = new Set<string>();
151
+ const fallback = (await isDirectory(options.fallbackCwd)) ? path.resolve(options.fallbackCwd as string) : undefined;
152
+ const scanned = new Map<ImportSource, Map<string, ExternalSessionSummary>>();
153
+ const seen = new Set<string>();
154
+
155
+ const resolveSummary = async (source: ImportSource, externalId: string): Promise<ExternalSessionSummary | undefined> => {
156
+ let cache = scanned.get(source);
157
+ if (!cache) {
158
+ const module = IMPORT_SOURCE_MODULES[source];
159
+ if (!module) return undefined;
160
+ cache = new Map();
161
+ // limit: 0 = 不限:这里要按 id 反查任意一条会话(面板只列最近的 15 条,
162
+ // 但选择来自面板、且可能已经过几分钟,所以解析一定要能看到全部,不能跟着截断)
163
+ for (const summary of await module.scan({ limit: 0 })) cache.set(summary.externalId, summary);
164
+ scanned.set(source, cache);
165
+ }
166
+ return cache.get(externalId);
167
+ };
168
+
169
+ let done = 0;
170
+ for (const item of items) {
171
+ const { source, externalId } = item;
172
+ const outcome: ImportOutcome = { source, externalId, status: "failed" };
173
+ try {
174
+ if (!IMPORT_SOURCE_MODULES[source]) {
175
+ outcome.reason = "不支持的来源";
176
+ } else if (seen.has(importedKey(source, externalId))) {
177
+ outcome.status = "skipped";
178
+ outcome.reason = "同一次导入里重复选择";
179
+ } else if (await findImported(options.agentDir, source, externalId)) {
180
+ outcome.status = "skipped";
181
+ outcome.reason = "已经导入过";
182
+ } else {
183
+ const summary = await resolveSummary(source, externalId);
184
+ if (!summary) {
185
+ outcome.reason = "源会话已不存在(可能被源工具删掉或移动)";
186
+ } else if (await oversize(summary.location)) {
187
+ outcome.reason = "源会话过大(超过 200MB),已跳过";
188
+ } else {
189
+ const session = await IMPORT_SOURCE_MODULES[source].read(summary);
190
+ if (!session) {
191
+ outcome.reason = "源会话读不出可导入的消息";
192
+ } else if (session.entries.length > MAX_ENTRIES) {
193
+ outcome.reason = `消息过多(${session.entries.length} 条),已跳过`;
194
+ } else {
195
+ // 源项目路径本机存在就导回原工作区,否则落到用户选的兜底工作区
196
+ const sourceProject = session.summary.projectPath;
197
+ const target = (await isDirectory(sourceProject)) ? path.resolve(sourceProject as string) : fallback;
198
+ if (!target) {
199
+ outcome.reason = sourceProject ? `源项目目录不存在:${sourceProject}` : "源会话没有项目目录,且未选择兜底工作区";
200
+ } else {
201
+ const written = await writeImportedSession(session, { cwd: target, agentDir: options.agentDir, now: options.now });
202
+ outcome.status = "imported";
203
+ outcome.path = written.path;
204
+ outcome.messageCount = written.messageCount;
205
+ outcome.workspace = target;
206
+ outcome.title = session.summary.title;
207
+ if (written.skipped.length) outcome.skipped = written.skipped;
208
+ workspaces.add(target);
209
+ seen.add(importedKey(source, externalId));
210
+ }
211
+ }
212
+ }
213
+ }
214
+ } catch (error) {
215
+ outcome.reason = error instanceof Error ? error.message : String(error);
216
+ }
217
+ results.push(outcome);
218
+ done += 1;
219
+ options.onProgress?.(done, items.length);
220
+ }
221
+
222
+ return {
223
+ imported: results.filter((result) => result.status === "imported").length,
224
+ skipped: results.filter((result) => result.status === "skipped").length,
225
+ failed: results.filter((result) => result.status === "failed").length,
226
+ results,
227
+ workspaces: [...workspaces],
228
+ };
229
+ }
@@ -0,0 +1,275 @@
1
+ /**
2
+ * ZCode 与 opencode 的会话库是同一套血缘(session / message / part 三表,data 列存 JSON),
3
+ * 差别只在排序字段与少量列名,所以共用这一份适配器,各自只传自己的库路径与来源 id。
4
+ *
5
+ * 只读、只取顶层会话(parent_id 为空):库里的 parent_id 指子代理/侧链会话,本机 ZCode 421 条里有
6
+ * 330 条是这种,导进来只会淹没侧栏;对应用户"只取主链"的要求。
7
+ */
8
+ import type { ExternalSessionSummary, ImportedBlock, ImportedEntry, ImportedMessage, ImportedSession, ImportSource, ImportSourceModule, ImportedUsage, ScanOptions } from "./types";
9
+ import { cleanTitle, compactBlocks, outputBlocks, textBlock, toEpochMs } from "./types";
10
+ import { openReadOnly, parseJsonColumn, type ReadOnlyDb } from "./sqlite";
11
+ import { zcodeDb, opencodeDb } from "./paths";
12
+
13
+ interface FamilyOptions {
14
+ id: ImportSource;
15
+ /** 库文件绝对路径 */
16
+ dbPath(): string;
17
+ }
18
+
19
+ /** `data:` URL 形式的附件能还原成真正的图片块(opencode 用这种) */
20
+ function imageFromUrl(url: unknown, mime: unknown): ImportedBlock | null {
21
+ if (typeof url !== "string" || !url.startsWith("data:")) return null;
22
+ const match = /^data:([^;,]+);base64,(.+)$/s.exec(url);
23
+ if (!match) return null;
24
+ return { type: "image", data: match[2], mimeType: typeof mime === "string" && mime ? mime : match[1] };
25
+ }
26
+
27
+ function orderClause(db: ReadOnlyDb, table: string, fallback: string): string {
28
+ return db.hasColumn(table, "sequence") ? `sequence, time_created, id` : fallback;
29
+ }
30
+
31
+ /** 消息里的 provider/model 列名两代不同:zcode 用 modelId/providerId,opencode 用 modelID/providerID */
32
+ function modelOf(data: Record<string, unknown>): { provider?: string; model?: string } {
33
+ const pick = (camel: string) => {
34
+ const value = data[camel];
35
+ return typeof value === "string" && value ? value : undefined;
36
+ };
37
+ return { provider: pick("providerID") ?? pick("providerId"), model: pick("modelID") ?? pick("modelId") };
38
+ }
39
+
40
+ function usageOf(data: Record<string, unknown>): ImportedUsage {
41
+ const tokens = data.tokens as Record<string, unknown> | undefined;
42
+ if (!tokens) return {};
43
+ const cache = tokens.cache as Record<string, unknown> | undefined;
44
+ const num = (value: unknown) => (typeof value === "number" && Number.isFinite(value) ? value : undefined);
45
+ return {
46
+ input: num(tokens.input),
47
+ output: num(tokens.output),
48
+ reasoning: num(tokens.reasoning),
49
+ cacheRead: num(cache?.read),
50
+ cacheWrite: num(cache?.write),
51
+ totalTokens: num(tokens.total),
52
+ };
53
+ }
54
+
55
+ /** 消息内容块(user 消息也会带 file 附件) */
56
+ function partBlocks(data: Record<string, unknown>): { blocks: ImportedBlock[]; ignored: number } {
57
+ const type = typeof data.type === "string" ? data.type : "";
58
+ if (type === "text") return { blocks: compactBlocks([textBlock(data.text)]), ignored: 0 };
59
+ if (type === "file") {
60
+ const image = imageFromUrl(data.url, data.mime);
61
+ if (image) return { blocks: [image], ignored: 0 };
62
+ // zcode 的附件是 zcode-artifact:// 协议,取不出字节:留一条文本注记而不是丢弃
63
+ const name = typeof data.filename === "string" ? data.filename : typeof data.mime === "string" ? data.mime : "附件";
64
+ return { blocks: compactBlocks([textBlock(`[附件: ${name}]`)]), ignored: 0 };
65
+ }
66
+ return { blocks: [], ignored: 1 };
67
+ }
68
+
69
+ /**
70
+ * 一条 message = 一次模型请求(opencode 血缘的表结构就是这样),所以按 pi 的原生形状合成:
71
+ * **一条 assistant 消息**(思考 + 正文 + 全部 toolCall,与 pi 自己写出来的完全一致),
72
+ * 后面紧跟每个调用的 toolResult。拆成多条反而会打散一次请求内的调用/结果配对,
73
+ * 用量也只能挂到最后一个碎片上。
74
+ */
75
+ function assistantEntries(parts: Record<string, unknown>[], base: { timestamp: number; provider?: string; model?: string }): { entries: ImportedEntry[]; ignored: number } {
76
+ const entries: ImportedEntry[] = [];
77
+ let ignored = 0;
78
+ let blocks: ImportedBlock[] = [];
79
+ const results: Array<{ toolCallId: string; toolName: string; content: ImportedBlock[]; isError: boolean }> = [];
80
+
81
+ for (const data of parts) {
82
+ const type = typeof data.type === "string" ? data.type : "";
83
+ if (type === "text") {
84
+ const block = textBlock(data.text);
85
+ if (block) blocks.push(block);
86
+ continue;
87
+ }
88
+ if (type === "reasoning") {
89
+ // 加密推理只有密文(opencode 会存 reasoningEncryptedContent):无正文可导,丢掉
90
+ if (typeof data.text === "string" && data.text.trim()) blocks.push({ type: "thinking", thinking: data.text });
91
+ continue;
92
+ }
93
+ if (type === "tool") {
94
+ const callId = typeof data.callID === "string" ? data.callID : "";
95
+ const name = typeof data.tool === "string" ? data.tool : "";
96
+ const state = data.state as Record<string, unknown> | undefined;
97
+ if (!callId || !name) continue;
98
+ blocks.push({ type: "toolCall", id: callId, name, arguments: state?.input ?? {} });
99
+ const failed = state?.status === "error";
100
+ const content = outputBlocks(state?.output ?? state?.error ?? "");
101
+ results.push({
102
+ toolCallId: callId,
103
+ toolName: name,
104
+ content: content.length ? content : compactBlocks([textBlock(failed ? "工具执行失败" : "(无输出)")]),
105
+ isError: failed,
106
+ });
107
+ continue;
108
+ }
109
+ // step-start / step-finish / timeline / compaction / patch 等:pi 侧没有对应概念或会重复
110
+ ignored += 1;
111
+ }
112
+
113
+ const hasCall = results.length > 0;
114
+ if (blocks.length) {
115
+ entries.push({
116
+ type: "message",
117
+ message: {
118
+ role: "assistant",
119
+ content: blocks,
120
+ timestamp: base.timestamp,
121
+ stopReason: hasCall ? "toolUse" : "stop",
122
+ ...(base.provider ? { provider: base.provider } : {}),
123
+ ...(base.model ? { model: base.model } : {}),
124
+ },
125
+ });
126
+ }
127
+ for (const result of results) {
128
+ entries.push({
129
+ type: "message",
130
+ message: {
131
+ role: "toolResult",
132
+ toolCallId: result.toolCallId,
133
+ toolName: result.toolName,
134
+ content: result.content,
135
+ ...(result.isError ? { isError: true } : {}),
136
+ timestamp: base.timestamp,
137
+ },
138
+ });
139
+ }
140
+ return { entries, ignored };
141
+ }
142
+
143
+ function createSource(options: FamilyOptions): ImportSourceModule {
144
+ async function open(): Promise<ReadOnlyDb | null> {
145
+ return openReadOnly(options.dbPath());
146
+ }
147
+
148
+ return {
149
+ id: options.id,
150
+
151
+ async scan(scanOptions?: ScanOptions): Promise<ExternalSessionSummary[]> {
152
+ const db = await open();
153
+ if (!db) return [];
154
+ try {
155
+ if (!db.hasTable("session") || !db.hasTable("message")) return [];
156
+ // 空会话(只有标题没有消息)与子代理会话直接在 SQL 里排掉,这样 LIMIT 才是"最近 N 条有效会话"
157
+ const limit = scanOptions?.limit ?? 0;
158
+ const rows = db.all(
159
+ `SELECT s.id AS id, s.title AS title, s.directory AS directory, s.time_created AS created, s.time_updated AS updated,
160
+ (SELECT COUNT(*) FROM message m WHERE m.session_id = s.id) AS msg_count
161
+ FROM session s
162
+ WHERE s.parent_id IS NULL AND (SELECT COUNT(*) FROM message m WHERE m.session_id = s.id) > 0
163
+ ORDER BY COALESCE(s.time_updated, s.time_created) DESC${limit > 0 ? " LIMIT ?" : ""}`,
164
+ limit > 0 ? [limit] : [],
165
+ );
166
+ const file = options.dbPath();
167
+ const out: ExternalSessionSummary[] = [];
168
+ for (const row of rows) {
169
+ const id = typeof row.id === "string" ? row.id : "";
170
+ if (!id) continue;
171
+ const title = cleanTitle(/^New session - /.test(String(row.title)) ? undefined : row.title);
172
+ out.push({
173
+ source: options.id,
174
+ externalId: id,
175
+ title,
176
+ projectPath: typeof row.directory === "string" ? row.directory : undefined,
177
+ createdAt: toEpochMs(row.created),
178
+ updatedAt: toEpochMs(row.updated),
179
+ messageCount: typeof row.msg_count === "number" ? row.msg_count : undefined,
180
+ location: `${file}|${id}`,
181
+ });
182
+ }
183
+ return out;
184
+ } finally {
185
+ db.close();
186
+ }
187
+ },
188
+
189
+ async read(summary: ExternalSessionSummary): Promise<ImportedSession | null> {
190
+ const separator = summary.location.lastIndexOf("|");
191
+ if (separator < 0) return null;
192
+ const file = summary.location.slice(0, separator);
193
+ const sessionId = summary.location.slice(separator + 1);
194
+ const db = await openReadOnly(file);
195
+ if (!db) return null;
196
+ try {
197
+ const session = db.get("SELECT title, directory, time_created FROM session WHERE id = ?", [sessionId]);
198
+ const messages = db.all(`SELECT id, data FROM message WHERE session_id = ? ORDER BY ${orderClause(db, "message", "time_created, id")}`, [sessionId]);
199
+ const parts = db.all(`SELECT message_id, data FROM part WHERE session_id = ? ORDER BY ${orderClause(db, "part", "message_id, time_created, id")}`, [sessionId]);
200
+ const partsByMessage = new Map<string, Record<string, unknown>[]>();
201
+ for (const row of parts) {
202
+ const messageId = typeof row.message_id === "string" ? row.message_id : "";
203
+ const data = parseJsonColumn(row.data);
204
+ if (!messageId || !data) continue;
205
+ const list = partsByMessage.get(messageId);
206
+ if (list) list.push(data);
207
+ else partsByMessage.set(messageId, [data]);
208
+ }
209
+
210
+ const entries: ImportedEntry[] = [];
211
+ let ignored = 0;
212
+ let provider: string | undefined;
213
+ let model: string | undefined;
214
+ let lastAt = toEpochMs(session?.time_created) ?? summary.createdAt ?? Date.now();
215
+
216
+ for (const row of messages) {
217
+ const id = typeof row.id === "string" ? row.id : "";
218
+ const data = parseJsonColumn(row.data);
219
+ if (!data) continue;
220
+ const time = data.time as Record<string, unknown> | undefined;
221
+ const at = toEpochMs(time?.created) ?? toEpochMs(time?.completed) ?? lastAt;
222
+ lastAt = at;
223
+ const own = partsByMessage.get(id) ?? [];
224
+ const role = typeof data.role === "string" ? data.role : "";
225
+
226
+ if (role === "user") {
227
+ const blocks: ImportedBlock[] = [];
228
+ for (const part of own) {
229
+ const result = partBlocks(part);
230
+ blocks.push(...result.blocks);
231
+ ignored += result.ignored;
232
+ }
233
+ if (!blocks.length) continue;
234
+ entries.push({ type: "message", message: { role: "user", content: blocks, timestamp: at } });
235
+ continue;
236
+ }
237
+ if (role !== "assistant") continue;
238
+
239
+ const info = modelOf(data);
240
+ provider = info.provider ?? provider;
241
+ model = info.model ?? model;
242
+ const usage = usageOf(data);
243
+ const expanded = assistantEntries(own, { timestamp: at, provider: info.provider, model: info.model });
244
+ ignored += expanded.ignored;
245
+ // 一次请求的 token 记在这条消息**最后一个 assistant 条目**上(末尾常是工具结果,
246
+ // 所以不能简单取 entries 的最后一项目),否则同一笔用量会被数多次或整个丢掉
247
+ const usageIndex = expanded.entries.reduce((last, entry, index) => (entry.type === "message" && entry.message.role === "assistant" ? index : last), -1);
248
+ expanded.entries.forEach((entry, index) => {
249
+ if (index === usageIndex && entry.type === "message" && entry.message.role === "assistant" && Object.keys(usage).length) {
250
+ entries.push({ type: "message", message: { ...entry.message, usage } });
251
+ return;
252
+ }
253
+ entries.push(entry);
254
+ });
255
+ }
256
+
257
+ if (!entries.some((entry) => entry.type === "message")) return null;
258
+ const title = cleanTitle(/^New session - /.test(String(session?.title)) ? undefined : session?.title) ?? cleanTitle(summary.title);
259
+ const skipped: string[] = [];
260
+ if (ignored) skipped.push(`跳过 ${ignored} 个内部片段(步骤标记 / 压缩记录 / 补丁快照)`);
261
+ return {
262
+ summary: { ...summary, title, provider, model, projectPath: (typeof session?.directory === "string" ? session.directory : summary.projectPath) },
263
+ entries,
264
+ skipped,
265
+ };
266
+ } finally {
267
+ db.close();
268
+ }
269
+ },
270
+ };
271
+ }
272
+
273
+ export function createOcFamilySource(id: ImportSource, file: () => string): ImportSourceModule {
274
+ return createSource({ id, dbPath: file });
275
+ }
@@ -0,0 +1,7 @@
1
+ /**
2
+ * opencode:`~/.local/share/opencode/opencode.db`(与 ZCode 同源的表结构)。
3
+ */
4
+ import { opencodeDb } from "./paths";
5
+ import { createOcFamilySource } from "./oc-family";
6
+
7
+ export const opencodeSource = createOcFamilySource("opencode", opencodeDb);
@@ -0,0 +1,37 @@
1
+ /**
2
+ * 各来源在本机的固定位置(一处收口,方便核对与测试替身)。
3
+ *
4
+ * 全部基于用户主目录;这些目录**只读**——导入过程中不往源目录写任何东西。
5
+ */
6
+ import os from "node:os";
7
+ import path from "node:path";
8
+
9
+ /** Claude Code:~/.claude/projects/<项目 slug>/<会话 uuid>.jsonl */
10
+ export function claudeRoot(): string {
11
+ return path.join(os.homedir(), ".claude", "projects");
12
+ }
13
+
14
+ /** Codex CLI:~/.codex/sessions/YYYY/MM/DD/rollout-*.jsonl */
15
+ export function codexRoot(): string {
16
+ return path.join(os.homedir(), ".codex", "sessions");
17
+ }
18
+
19
+ /** Grok CLI:~/.grok/sessions/<url 编码 cwd>/<id>/{summary.json,chat_history.jsonl} */
20
+ export function grokRoot(): string {
21
+ return path.join(os.homedir(), ".grok", "sessions");
22
+ }
23
+
24
+ /** dsh:~/.dsh/sessions/<编码 cwd>/session-<uuid>/session.jsonl.zstd */
25
+ export function dshRoot(): string {
26
+ return path.join(os.homedir(), ".dsh", "sessions");
27
+ }
28
+
29
+ /** ZCode:~/.zcode/cli/db/db.sqlite */
30
+ export function zcodeDb(): string {
31
+ return path.join(os.homedir(), ".zcode", "cli", "db", "db.sqlite");
32
+ }
33
+
34
+ /** opencode:~/.local/share/opencode/opencode.db(Windows 上也是这个 XDG 风格路径) */
35
+ export function opencodeDb(): string {
36
+ return path.join(os.homedir(), ".local", "share", "opencode", "opencode.db");
37
+ }
@@ -0,0 +1,89 @@
1
+ /**
2
+ * 只读 SQLite 封装(node:sqlite 的 DatabaseSync,Node 24 起无需 flag)。
3
+ *
4
+ * 导入过程中**绝不能写源库**:ZCode / opencode 可能正在跑,写一下就是数据损坏。
5
+ * 这里三重保险——`readOnly: true`、`PRAGMA query_only = 1`、只暴露查询方法(没有 exec/run 出口)。
6
+ *
7
+ * `node:sqlite` 是实验特性,所以**按需**动态载入:载不进来(Node 太老)时只有 ZCode/opencode
8
+ * 这两个来源报错,文件类的四个来源照常可用——scanAllSources 会把单来源失败隔离掉。
9
+ */
10
+ import type { DatabaseSync } from "node:sqlite";
11
+
12
+ export interface ReadOnlyDb {
13
+ all(sql: string, params?: Array<string | number | null>): Record<string, unknown>[];
14
+ get(sql: string, params?: Array<string | number | null>): Record<string, unknown> | undefined;
15
+ hasTable(name: string): boolean;
16
+ hasColumn(table: string, column: string): boolean;
17
+ close(): void;
18
+ }
19
+
20
+ export async function openReadOnly(file: string): Promise<ReadOnlyDb | null> {
21
+ const DatabaseSyncClass = await loadDriver();
22
+ if (!DatabaseSyncClass) return null;
23
+ let db: DatabaseSync;
24
+ try {
25
+ db = new DatabaseSyncClass(file, { readOnly: true });
26
+ } catch {
27
+ return null; // 文件不存在 / 不是 SQLite / 被占用到无法只读打开
28
+ }
29
+ try {
30
+ db.exec("PRAGMA query_only = 1");
31
+ } catch {
32
+ // 老版本不支持该 pragma:readOnly 仍然生效
33
+ }
34
+ const all = (sql: string, params: Array<string | number | null> = []) => {
35
+ try {
36
+ return db.prepare(sql).all(...params) as Record<string, unknown>[];
37
+ } catch {
38
+ return [];
39
+ }
40
+ };
41
+ return {
42
+ all,
43
+ get: (sql, params) => all(sql, params)[0],
44
+ hasTable: (name) => Boolean(all("SELECT 1 FROM sqlite_master WHERE type = 'table' AND name = ?", [name]).length),
45
+ hasColumn: (table, column) => {
46
+ try {
47
+ return (db.prepare(`PRAGMA table_info(${table})`).all() as Array<{ name: string }>).some((row) => row.name === column);
48
+ } catch {
49
+ return false;
50
+ }
51
+ },
52
+ close: () => {
53
+ try {
54
+ db.close();
55
+ } catch {
56
+ // 已经关了:无事可做
57
+ }
58
+ },
59
+ };
60
+ }
61
+
62
+ type DatabaseSyncCtor = typeof DatabaseSync;
63
+
64
+ let cachedDriver: Promise<DatabaseSyncCtor | null> | null = null;
65
+
66
+ /** 动态载入 node:sqlite;失败只影响 ZCode/opencode 这两个来源 */
67
+ function loadDriver(): Promise<DatabaseSyncCtor | null> {
68
+ if (!cachedDriver) {
69
+ cachedDriver = import("node:sqlite").then(
70
+ (module) => module.DatabaseSync,
71
+ (error) => {
72
+ if (process.env.PIWEB_DEBUG_PROMPT) console.warn("[piweb] node:sqlite 不可用:", error instanceof Error ? error.message : error);
73
+ return null;
74
+ },
75
+ );
76
+ }
77
+ return cachedDriver;
78
+ }
79
+
80
+ /** data 列里的 JSON:坏行返回 null 而不是抛错 */
81
+ export function parseJsonColumn(value: unknown): Record<string, unknown> | null {
82
+ if (typeof value !== "string" || !value) return null;
83
+ try {
84
+ const parsed = JSON.parse(value);
85
+ return parsed && typeof parsed === "object" ? (parsed as Record<string, unknown>) : null;
86
+ } catch {
87
+ return null;
88
+ }
89
+ }