@rexvane/piweb 0.3.11 → 0.3.13
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 +8 -6
- package/.next/build-manifest.json +6 -6
- package/.next/prerender-manifest.json +7 -7
- package/.next/react-loadable-manifest.json +7 -7
- package/.next/routes-manifest.json +12 -0
- package/.next/server/app/_global-error/page.js +2 -2
- 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.js +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 +2 -2
- package/.next/server/app/api/agent/[id]/events/route.js.nft.json +1 -1
- package/.next/server/app/api/agent/[id]/route.js +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 +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 +1 -1
- package/.next/server/app/api/agent/new/route.js.nft.json +1 -1
- package/.next/server/app/api/config/route.js +1 -1
- package/.next/server/app/api/files/route.js +1 -1
- package/.next/server/app/api/git/route.js +2 -2
- package/.next/server/app/api/growth/route.js +1 -1
- package/.next/server/app/api/growth/route.js.nft.json +1 -1
- package/.next/server/app/api/health/route.js +1 -1
- package/.next/server/app/api/models/route.js +1 -1
- package/.next/server/app/api/models/route.js.nft.json +1 -1
- package/.next/server/app/api/pi-settings/route.js +1 -1
- package/.next/server/app/api/pi-settings/route.js.nft.json +1 -1
- package/.next/server/app/api/plugins/route.js +1 -1
- package/.next/server/app/api/plugins/route.js.nft.json +1 -1
- package/.next/server/app/api/prompts/route.js +1 -0
- package/.next/server/app/api/prompts/route.js.nft.json +1 -0
- package/.next/server/app/api/prompts/route_client-reference-manifest.js +1 -0
- package/.next/server/app/api/security/route.js +1 -1
- package/.next/server/app/api/security/route.js.nft.json +1 -1
- package/.next/server/app/api/sessions/[id]/export/route.js +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 +1 -1
- package/.next/server/app/api/sessions/[id]/route.js.nft.json +1 -1
- package/.next/server/app/api/sessions/import/route.js +6 -0
- package/.next/server/app/api/sessions/import/route.js.nft.json +1 -0
- package/.next/server/app/api/sessions/import/route_client-reference-manifest.js +1 -0
- package/.next/server/app/api/sessions/route.js +1 -1
- 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 +2 -2
- 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/web-auth/route.js +1 -1
- package/.next/server/app/api/workspaces/route.js +1 -1
- package/.next/server/app/api/workspaces/route.js.nft.json +1 -1
- package/.next/server/app/icon.svg/route.js +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.js +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.js +1 -1
- package/.next/server/app/page_client-reference-manifest.js +1 -1
- package/.next/server/app-paths-manifest.json +8 -6
- package/.next/server/chunks/496.js +1 -1
- package/.next/server/chunks/532.js +1 -0
- package/.next/server/chunks/686.js +2 -2
- 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/2155.2f27fe7098987699.js +1 -0
- package/.next/static/chunks/2608.ec8ff1f5767c9056.js +1 -0
- package/.next/static/chunks/2716.a9e808190a4e2424.js +1 -0
- package/.next/static/chunks/{794-d1a40dcf24d651d9.js → 3794-3679f1c05160977c.js} +1 -1
- package/.next/static/chunks/4632.d574c210ad78d065.js +1 -0
- package/.next/static/chunks/4673.d3d0a7f2599080fb.js +1 -0
- package/.next/static/chunks/4686-0a4155f8488314b8.js +1 -0
- package/.next/static/chunks/4bd1b696-93bf506b236d6248.js +1 -0
- package/.next/static/chunks/app/_global-error/page-f77e9ede9808b18e.js +1 -0
- package/.next/static/chunks/app/_not-found/page-2d40d1d7d916666a.js +1 -0
- package/.next/static/chunks/app/api/agent/[id]/events/route-f77e9ede9808b18e.js +1 -0
- package/.next/static/chunks/app/api/agent/[id]/route-f77e9ede9808b18e.js +1 -0
- package/.next/static/chunks/app/api/agent/[id]/tree/route-f77e9ede9808b18e.js +1 -0
- package/.next/static/chunks/app/api/agent/new/route-f77e9ede9808b18e.js +1 -0
- package/.next/static/chunks/app/api/config/route-f77e9ede9808b18e.js +1 -0
- package/.next/static/chunks/app/api/files/route-f77e9ede9808b18e.js +1 -0
- package/.next/static/chunks/app/api/git/route-f77e9ede9808b18e.js +1 -0
- package/.next/static/chunks/app/api/growth/route-f77e9ede9808b18e.js +1 -0
- package/.next/static/chunks/app/api/health/route-f77e9ede9808b18e.js +1 -0
- package/.next/static/chunks/app/api/models/route-f77e9ede9808b18e.js +1 -0
- package/.next/static/chunks/app/api/pi-settings/route-f77e9ede9808b18e.js +1 -0
- package/.next/static/chunks/app/api/plugins/route-f77e9ede9808b18e.js +1 -0
- package/.next/static/chunks/app/api/prompts/route-f77e9ede9808b18e.js +1 -0
- package/.next/static/chunks/app/api/security/route-f77e9ede9808b18e.js +1 -0
- package/.next/static/chunks/app/api/sessions/[id]/export/route-f77e9ede9808b18e.js +1 -0
- package/.next/static/chunks/app/api/sessions/[id]/route-f77e9ede9808b18e.js +1 -0
- package/.next/static/chunks/app/api/sessions/import/route-f77e9ede9808b18e.js +1 -0
- package/.next/static/chunks/app/api/sessions/route-f77e9ede9808b18e.js +1 -0
- package/.next/static/chunks/app/api/skills/route-f77e9ede9808b18e.js +1 -0
- package/.next/static/chunks/app/api/update/route-f77e9ede9808b18e.js +1 -0
- package/.next/static/chunks/app/api/version/route-f77e9ede9808b18e.js +1 -0
- package/.next/static/chunks/app/api/web-auth/route-f77e9ede9808b18e.js +1 -0
- package/.next/static/chunks/app/api/workspaces/route-f77e9ede9808b18e.js +1 -0
- package/.next/static/chunks/app/layout-50d06e2078d6ce76.js +1 -0
- package/.next/static/chunks/app/login/page-52ef4841cbd199a8.js +1 -0
- package/.next/static/chunks/app/page-9a5440047c5a415c.js +1 -0
- package/.next/static/chunks/framework-c4e651fe89edfa91.js +1 -0
- package/.next/static/chunks/{main-a315d7c58b599962.js → main-6159b22a8cb0b7c5.js} +2 -2
- package/.next/static/chunks/main-app-e9b53f0e1c6591b2.js +1 -0
- package/.next/static/chunks/next/dist/client/components/builtin/app-error-f77e9ede9808b18e.js +1 -0
- package/.next/static/chunks/next/dist/client/components/builtin/forbidden-f77e9ede9808b18e.js +1 -0
- package/.next/static/chunks/next/dist/client/components/builtin/global-error-a7e260f8cc2582d7.js +1 -0
- package/.next/static/chunks/next/dist/client/components/builtin/not-found-f77e9ede9808b18e.js +1 -0
- package/.next/static/chunks/next/dist/client/components/builtin/unauthorized-f77e9ede9808b18e.js +1 -0
- package/.next/static/chunks/webpack-b1a572e319353f5d.js +1 -0
- package/.next/static/olV7jxKRN5VhfEK012WhV/_buildManifest.js +1 -0
- package/.next/types/app/api/prompts/route.ts +353 -0
- package/.next/types/app/api/sessions/import/route.ts +353 -0
- package/.next/types/routes.d.ts +3 -1
- package/.next/types/validator.ts +18 -0
- package/README.md +4 -0
- package/package.json +1 -1
- package/src/app/api/prompts/route.ts +51 -0
- package/src/app/api/sessions/import/route.ts +74 -0
- package/src/components/AppShell.tsx +42 -19
- package/src/components/ChatWindow.tsx +20 -35
- package/src/components/PromptPanel.tsx +259 -0
- package/src/components/SessionImportSection.tsx +333 -0
- package/src/components/SettingsPanel.tsx +63 -57
- package/src/i18n.tsx +98 -4
- package/src/lib/agent-manager.ts +35 -8
- package/src/lib/clipboard.ts +27 -0
- package/src/lib/command-validation.ts +1 -0
- package/src/lib/prompt-sources.ts +229 -0
- package/src/lib/session-import/claude.ts +219 -0
- package/src/lib/session-import/codex.ts +206 -0
- package/src/lib/session-import/dsh-zstd.ts +69 -0
- package/src/lib/session-import/dsh.ts +248 -0
- package/src/lib/session-import/files.ts +84 -0
- package/src/lib/session-import/grok.ts +166 -0
- package/src/lib/session-import/index.ts +229 -0
- package/src/lib/session-import/oc-family.ts +275 -0
- package/src/lib/session-import/opencode.ts +7 -0
- package/src/lib/session-import/paths.ts +37 -0
- package/src/lib/session-import/sqlite.ts +89 -0
- package/src/lib/session-import/types.ts +171 -0
- package/src/lib/session-import/writer.ts +259 -0
- package/src/lib/session-import/zcode.ts +8 -0
- package/src/lib/types.ts +1 -0
- package/.next/server/chunks/118.js +0 -1
- package/.next/static/222KLUIvefiGf7M0Ov-Qz/_buildManifest.js +0 -1
- package/.next/static/chunks/127.c31e688e156605e3.js +0 -1
- package/.next/static/chunks/155.a3c40ae481da1358.js +0 -1
- package/.next/static/chunks/4bd1b696-92152b0f5947070d.js +0 -1
- package/.next/static/chunks/608.5b8aae42a9998ee7.js +0 -1
- package/.next/static/chunks/673.93445c8600bc1dd9.js +0 -1
- package/.next/static/chunks/686-9f15e4e370d26815.js +0 -1
- package/.next/static/chunks/716.5266fe10b5578591.js +0 -1
- package/.next/static/chunks/app/_global-error/page-1dac1b3ca8adb975.js +0 -1
- package/.next/static/chunks/app/_not-found/page-9de30f6f614fce02.js +0 -1
- package/.next/static/chunks/app/api/agent/[id]/events/route-1dac1b3ca8adb975.js +0 -1
- package/.next/static/chunks/app/api/agent/[id]/route-1dac1b3ca8adb975.js +0 -1
- package/.next/static/chunks/app/api/agent/[id]/tree/route-1dac1b3ca8adb975.js +0 -1
- package/.next/static/chunks/app/api/agent/new/route-1dac1b3ca8adb975.js +0 -1
- package/.next/static/chunks/app/api/config/route-1dac1b3ca8adb975.js +0 -1
- package/.next/static/chunks/app/api/files/route-1dac1b3ca8adb975.js +0 -1
- package/.next/static/chunks/app/api/git/route-1dac1b3ca8adb975.js +0 -1
- package/.next/static/chunks/app/api/growth/route-1dac1b3ca8adb975.js +0 -1
- package/.next/static/chunks/app/api/health/route-1dac1b3ca8adb975.js +0 -1
- package/.next/static/chunks/app/api/models/route-1dac1b3ca8adb975.js +0 -1
- package/.next/static/chunks/app/api/pi-settings/route-1dac1b3ca8adb975.js +0 -1
- package/.next/static/chunks/app/api/plugins/route-1dac1b3ca8adb975.js +0 -1
- package/.next/static/chunks/app/api/security/route-1dac1b3ca8adb975.js +0 -1
- package/.next/static/chunks/app/api/sessions/[id]/export/route-1dac1b3ca8adb975.js +0 -1
- package/.next/static/chunks/app/api/sessions/[id]/route-1dac1b3ca8adb975.js +0 -1
- package/.next/static/chunks/app/api/sessions/route-1dac1b3ca8adb975.js +0 -1
- package/.next/static/chunks/app/api/skills/route-1dac1b3ca8adb975.js +0 -1
- package/.next/static/chunks/app/api/update/route-1dac1b3ca8adb975.js +0 -1
- package/.next/static/chunks/app/api/version/route-1dac1b3ca8adb975.js +0 -1
- package/.next/static/chunks/app/api/web-auth/route-1dac1b3ca8adb975.js +0 -1
- package/.next/static/chunks/app/api/workspaces/route-1dac1b3ca8adb975.js +0 -1
- package/.next/static/chunks/app/layout-eb62bcd99abe79f9.js +0 -1
- package/.next/static/chunks/app/login/page-d2308b48d013d1a6.js +0 -1
- package/.next/static/chunks/app/page-708cd3ee4cfe8af5.js +0 -1
- package/.next/static/chunks/framework-0f597d6e68ffafed.js +0 -1
- package/.next/static/chunks/main-app-1a92e20ec190a32b.js +0 -1
- package/.next/static/chunks/next/dist/client/components/builtin/app-error-1dac1b3ca8adb975.js +0 -1
- package/.next/static/chunks/next/dist/client/components/builtin/forbidden-1dac1b3ca8adb975.js +0 -1
- package/.next/static/chunks/next/dist/client/components/builtin/global-error-7bb5c4afcbc1772b.js +0 -1
- package/.next/static/chunks/next/dist/client/components/builtin/not-found-1dac1b3ca8adb975.js +0 -1
- package/.next/static/chunks/next/dist/client/components/builtin/unauthorized-1dac1b3ca8adb975.js +0 -1
- package/.next/static/chunks/webpack-9d84b382d4dec27a.js +0 -1
- /package/.next/static/{222KLUIvefiGf7M0Ov-Qz → olV7jxKRN5VhfEK012WhV}/_ssgManifest.js +0 -0
|
@@ -0,0 +1,166 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Grok CLI(~/.grok/sessions/<URL 编码的 cwd>/<session-uuid>/)
|
|
3
|
+
*
|
|
4
|
+
* 会话目录里有 chat_history.jsonl(正文)与 summary.json(标题/模型/时间/消息数)。
|
|
5
|
+
* chat_history 每行 `{type, content, ...}`:system / user / assistant(含 tool_calls[])/ reasoning / tool_result。
|
|
6
|
+
*/
|
|
7
|
+
import path from "node:path";
|
|
8
|
+
import fs from "node:fs/promises";
|
|
9
|
+
import { eachJsonLine, listFiles, readHead, reachedLimit, sortByRecency } from "./files";
|
|
10
|
+
import { grokRoot } from "./paths";
|
|
11
|
+
import type { ExternalSessionSummary, ImportedBlock, ImportedEntry, ImportedSession, ImportSourceModule, ScanOptions } from "./types";
|
|
12
|
+
import { cleanTitle, compactBlocks, outputBlocks, parseArguments, textBlock, toEpochMs } from "./types";
|
|
13
|
+
|
|
14
|
+
const ROOT = grokRoot;
|
|
15
|
+
|
|
16
|
+
/** 目录名是 URL 编码的 cwd(D%3A%5CAIApp%5CPiWeb),summary.json 缺失时用它兜底 */
|
|
17
|
+
function decodeProjectDir(name: string): string | undefined {
|
|
18
|
+
try {
|
|
19
|
+
const decoded = decodeURIComponent(name);
|
|
20
|
+
return /^[A-Za-z]:[\\/]/.test(decoded) || decoded.startsWith("/") ? decoded : undefined;
|
|
21
|
+
} catch {
|
|
22
|
+
return undefined;
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* 判断一段 chat_history 里是否有真正能导入的对话:至少一条 assistant,或一条不受合成标记影响的用户消息。
|
|
28
|
+
* 与 read() 的接受口径一致——只列"read 一定能读出来"的会话。
|
|
29
|
+
*/
|
|
30
|
+
function importable(records: Record<string, unknown>[]): boolean {
|
|
31
|
+
for (const record of records) {
|
|
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;
|
|
34
|
+
if (record.type !== "user" || record.synthetic_reason) continue;
|
|
35
|
+
const blocks = userBlocks(record.content);
|
|
36
|
+
if (blocks.some((block) => block.type === "text" && block.text.replace(/<[^>]+>/g, "").trim())) return true;
|
|
37
|
+
}
|
|
38
|
+
return false;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
async function hasRealMessage(history: string): Promise<boolean> {
|
|
42
|
+
try {
|
|
43
|
+
return importable([...eachJsonLine(await readHead(history))]);
|
|
44
|
+
} catch {
|
|
45
|
+
return false;
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/** user 的 content 可能是字符串或块数组 */function userBlocks(content: unknown): ImportedBlock[] {
|
|
50
|
+
if (typeof content === "string") return compactBlocks([textBlock(content)]);
|
|
51
|
+
if (!Array.isArray(content)) return [];
|
|
52
|
+
return compactBlocks(content.flatMap((raw): Array<ImportedBlock | null> => {
|
|
53
|
+
if (typeof raw === "string") return [textBlock(raw)];
|
|
54
|
+
const block = raw as Record<string, unknown>;
|
|
55
|
+
if (typeof block?.text === "string") return [textBlock(block.text)];
|
|
56
|
+
return [];
|
|
57
|
+
}));
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
export const grokSource: ImportSourceModule = {
|
|
61
|
+
id: "grok",
|
|
62
|
+
|
|
63
|
+
async scan(options?: ScanOptions): Promise<ExternalSessionSummary[]> {
|
|
64
|
+
// 先按 chat_history 的 mtime 排序(只 stat,不读内容),再从最近往下读:
|
|
65
|
+
// summary.json 里有标题/模型/时间/消息数,读它即可,正文只在确认有真实对话时碰一下头部
|
|
66
|
+
const limit = options?.limit ?? 0;
|
|
67
|
+
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>;
|
|
74
|
+
try {
|
|
75
|
+
summary = JSON.parse(await fs.readFile(path.join(dir, "summary.json"), "utf8")) as Record<string, unknown>;
|
|
76
|
+
} catch {
|
|
77
|
+
continue;
|
|
78
|
+
}
|
|
79
|
+
if (mtime === undefined) continue; // 没有正文就没什么可导
|
|
80
|
+
const info = summary.info as Record<string, unknown> | undefined;
|
|
81
|
+
const numMessages = Number(summary.num_messages ?? summary.num_chat_messages);
|
|
82
|
+
if (numMessages === 0) continue; // 开了没用的空会话
|
|
83
|
+
// 只有系统提示与合成提醒(system_reminder)的会话读不出对话,别列进列表
|
|
84
|
+
if (!(await hasRealMessage(history))) continue;
|
|
85
|
+
out.push({
|
|
86
|
+
source: "grok",
|
|
87
|
+
externalId: path.basename(dir),
|
|
88
|
+
title: cleanTitle(summary.session_summary ?? summary.generated_title),
|
|
89
|
+
projectPath: typeof info?.cwd === "string" ? info.cwd : decodeProjectDir(path.basename(path.dirname(dir))),
|
|
90
|
+
model: typeof summary.current_model_id === "string" ? summary.current_model_id : undefined,
|
|
91
|
+
createdAt: toEpochMs(summary.created_at) ?? mtime,
|
|
92
|
+
updatedAt: toEpochMs(summary.updated_at ?? summary.last_active_at) ?? mtime,
|
|
93
|
+
...(Number.isFinite(numMessages) ? { messageCount: numMessages } : {}),
|
|
94
|
+
location: history,
|
|
95
|
+
});
|
|
96
|
+
}
|
|
97
|
+
return out;
|
|
98
|
+
},
|
|
99
|
+
|
|
100
|
+
async read(summary: ExternalSessionSummary): Promise<ImportedSession | null> {
|
|
101
|
+
let text: string;
|
|
102
|
+
try {
|
|
103
|
+
text = await fs.readFile(summary.location, "utf8");
|
|
104
|
+
} catch {
|
|
105
|
+
return null;
|
|
106
|
+
}
|
|
107
|
+
const entries: ImportedEntry[] = [];
|
|
108
|
+
const skipped: string[] = [];
|
|
109
|
+
let encryptedReasoning = 0;
|
|
110
|
+
let synthetic = 0;
|
|
111
|
+
let model = summary.model;
|
|
112
|
+
|
|
113
|
+
for (const record of eachJsonLine(text)) {
|
|
114
|
+
const timestamp = toEpochMs(record.timestamp ?? record.time ?? record.created_at) ?? summary.createdAt ?? Date.now();
|
|
115
|
+
switch (record.type) {
|
|
116
|
+
case "system":
|
|
117
|
+
continue; // 系统提示不进对话
|
|
118
|
+
case "user": {
|
|
119
|
+
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) {
|
|
122
|
+
synthetic += 1;
|
|
123
|
+
continue;
|
|
124
|
+
}
|
|
125
|
+
entries.push({ type: "message", message: { role: "user", content: blocks, timestamp } });
|
|
126
|
+
continue;
|
|
127
|
+
}
|
|
128
|
+
case "assistant": {
|
|
129
|
+
if (typeof record.model_id === "string" && record.model_id.trim()) model = record.model_id;
|
|
130
|
+
const blocks: Array<ImportedBlock | null> = [typeof record.content === "string" ? textBlock(record.content) : null];
|
|
131
|
+
for (const call of (record.tool_calls as Array<Record<string, unknown>> | undefined) ?? []) {
|
|
132
|
+
const id = typeof call.id === "string" ? call.id : "";
|
|
133
|
+
const name = typeof call.name === "string" ? call.name : "";
|
|
134
|
+
if (id && name) blocks.push({ type: "toolCall", id, name, arguments: parseArguments(call.arguments ?? {}) });
|
|
135
|
+
}
|
|
136
|
+
const content = compactBlocks(blocks);
|
|
137
|
+
if (!content.length) continue;
|
|
138
|
+
entries.push({
|
|
139
|
+
type: "message",
|
|
140
|
+
message: { role: "assistant", content, timestamp, ...(model ? { model } : {}), ...(content.some((block) => block.type === "toolCall") ? { stopReason: "toolUse" } : {}) },
|
|
141
|
+
});
|
|
142
|
+
continue;
|
|
143
|
+
}
|
|
144
|
+
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 } : {}) } });
|
|
147
|
+
else encryptedReasoning += 1; // 加密推理无正文可导:计数后汇总成一行,不要刷屏
|
|
148
|
+
continue;
|
|
149
|
+
}
|
|
150
|
+
case "tool_result": {
|
|
151
|
+
const callId = typeof record.tool_call_id === "string" ? record.tool_call_id : "";
|
|
152
|
+
if (!callId) continue;
|
|
153
|
+
entries.push({ type: "message", message: { role: "toolResult", toolCallId: callId, content: outputBlocks(record.content), timestamp } });
|
|
154
|
+
continue;
|
|
155
|
+
}
|
|
156
|
+
default:
|
|
157
|
+
continue;
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
if (synthetic) skipped.push(`跳过 ${synthetic} 条合成输入`);
|
|
162
|
+
if (encryptedReasoning) skipped.push(`跳过 ${encryptedReasoning} 条仅含加密内容的推理记录`);
|
|
163
|
+
if (!entries.some((entry) => entry.type === "message")) return null;
|
|
164
|
+
return { summary: { ...summary, model }, entries, skipped };
|
|
165
|
+
},
|
|
166
|
+
};
|
|
@@ -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
|
+
}
|