@wuyaos/pi-sync 1.1.0 → 1.2.0
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/PROMO.md +37 -73
- package/README.md +141 -132
- package/README.zh-CN.md +141 -132
- package/extensions/sync/archive.ts +86 -121
- package/extensions/sync/config.ts +57 -27
- package/extensions/sync/i18n.ts +203 -0
- package/extensions/sync/index.ts +20 -43
- package/extensions/sync/menus.ts +472 -171
- package/extensions/sync/restore.ts +108 -102
- package/extensions/sync/webdav.ts +33 -12
- package/package.json +13 -5
- package/pi-bootstrap.ps1 +33 -74
- package/docs/sync-menu.png +0 -0
- package/extensions/sync/session-sync.ts +0 -231
|
@@ -1,231 +0,0 @@
|
|
|
1
|
-
import type { ExtensionCommandContext, ExtensionContext } from "@earendil-works/pi-coding-agent";
|
|
2
|
-
import { SessionManager } from "@earendil-works/pi-coding-agent";
|
|
3
|
-
import * as fs from "node:fs";
|
|
4
|
-
import * as os from "node:os";
|
|
5
|
-
import * as path from "node:path";
|
|
6
|
-
import { enhancedSelect } from "../_shared/enhanced-select";
|
|
7
|
-
import { readJsonSafe } from "../_shared/json-io";
|
|
8
|
-
import { extractSessionTs } from "./archive";
|
|
9
|
-
import { SESSIONS_DIR, ensureDir, isProjectAllowed, loadConfig, saveConfig, type SyncConfig } from "./config";
|
|
10
|
-
import { ensureWebdavDirectory, sessionsWebdavBase, WEBDAV_SESSIONS_DIR, webdavAuth, webdavGetFile, webdavList, webdavPutFile } from "./webdav";
|
|
11
|
-
|
|
12
|
-
export interface SessionSyncState {
|
|
13
|
-
currentSessionFile?: string;
|
|
14
|
-
currentProjectDir?: string;
|
|
15
|
-
liveBackupTimer?: ReturnType<typeof setTimeout>;
|
|
16
|
-
turnCounter: number;
|
|
17
|
-
}
|
|
18
|
-
|
|
19
|
-
const state: SessionSyncState = { turnCounter: 0 };
|
|
20
|
-
|
|
21
|
-
export function setSessionContext(file?: string, dir?: string): void {
|
|
22
|
-
state.currentSessionFile = file;
|
|
23
|
-
state.currentProjectDir = dir ? path.basename(dir) : undefined;
|
|
24
|
-
state.turnCounter = 0;
|
|
25
|
-
}
|
|
26
|
-
|
|
27
|
-
export function refreshSessionFile(file?: string): void {
|
|
28
|
-
state.currentSessionFile = file ?? state.currentSessionFile;
|
|
29
|
-
}
|
|
30
|
-
|
|
31
|
-
export function getCurrentSessionFile(): string | undefined {
|
|
32
|
-
return state.currentSessionFile;
|
|
33
|
-
}
|
|
34
|
-
|
|
35
|
-
export function incrementTurnAndShouldSync(interval: number): boolean {
|
|
36
|
-
if (interval <= 0) return false;
|
|
37
|
-
state.turnCounter++;
|
|
38
|
-
return state.turnCounter % interval === 0;
|
|
39
|
-
}
|
|
40
|
-
|
|
41
|
-
export function currentProjectIsAllowed(config: SyncConfig): boolean {
|
|
42
|
-
return isProjectAllowed(state.currentProjectDir, config);
|
|
43
|
-
}
|
|
44
|
-
|
|
45
|
-
export function listSessionProjects(): string[] {
|
|
46
|
-
if (!fs.existsSync(SESSIONS_DIR)) return [];
|
|
47
|
-
return fs.readdirSync(SESSIONS_DIR, { withFileTypes: true })
|
|
48
|
-
.filter((entry) => entry.isDirectory() && entry.name.startsWith("--") && entry.name.endsWith("--"))
|
|
49
|
-
.map((entry) => entry.name)
|
|
50
|
-
.sort((a, b) => a.localeCompare(b, undefined, { numeric: true, sensitivity: "base" }));
|
|
51
|
-
}
|
|
52
|
-
|
|
53
|
-
export function sessionDirToPath(dirName: string): string {
|
|
54
|
-
let value = dirName;
|
|
55
|
-
if (value.startsWith("--")) value = value.slice(2);
|
|
56
|
-
if (value.endsWith("--")) value = value.slice(0, -2);
|
|
57
|
-
return value ? `/${value.replace(/-/g, "/")}` : dirName;
|
|
58
|
-
}
|
|
59
|
-
|
|
60
|
-
export function projectDirFromCwd(cwd: string): string {
|
|
61
|
-
return `--${cwd.replace(/\//g, "-")}--`;
|
|
62
|
-
}
|
|
63
|
-
|
|
64
|
-
export function describeSessionSelection(config: SyncConfig): string {
|
|
65
|
-
const count = config.sessionProjects.length;
|
|
66
|
-
if (count === 0) return config.sessionProjectMode === "whitelist" ? `whitelist: 0 projects (none)` : `blacklist: ALL projects (empty = all)`;
|
|
67
|
-
return `${config.sessionProjectMode}: ${count} project${count === 1 ? "" : "s"}`;
|
|
68
|
-
}
|
|
69
|
-
|
|
70
|
-
export async function updateLatestMarker(ctx: ExtensionContext, config: SyncConfig, projectDir: string, sessionFile: string): Promise<void> {
|
|
71
|
-
const filename = path.basename(sessionFile), sessionTs = extractSessionTs(filename);
|
|
72
|
-
if (!sessionTs) return;
|
|
73
|
-
const markerUrl = sessionsWebdavBase(config) + encodeURIComponent(projectDir) + "/_latest.json";
|
|
74
|
-
const temp = path.join(os.tmpdir(), `pi_latest_${process.pid}_${Date.now()}.json`);
|
|
75
|
-
let remoteTs: string | undefined;
|
|
76
|
-
try {
|
|
77
|
-
try { await webdavGetFile(markerUrl, temp, webdavAuth(config), ctx); remoteTs = readJsonSafe<{ sessionTs?: string }>(temp, {}).sessionTs; }
|
|
78
|
-
catch (error) { if (!(error instanceof Error) || !/HTTP 404/.test(error.message)) throw error; }
|
|
79
|
-
if (remoteTs && remoteTs >= sessionTs) return;
|
|
80
|
-
fs.writeFileSync(temp, JSON.stringify({ file: filename, sessionTs, machine: os.hostname(), uploadedAt: new Date().toISOString() }, null, 2));
|
|
81
|
-
await webdavPutFile(temp, markerUrl, webdavAuth(config), ctx);
|
|
82
|
-
} finally { fs.rmSync(temp, { force: true }); }
|
|
83
|
-
}
|
|
84
|
-
|
|
85
|
-
export async function uploadCurrentSession(ctx: ExtensionContext, silent = false): Promise<boolean> {
|
|
86
|
-
const config = loadConfig();
|
|
87
|
-
if (!state.currentSessionFile || !state.currentProjectDir) {
|
|
88
|
-
if (!silent) ctx.ui.notify("No active session file to upload.", "warning");
|
|
89
|
-
return false;
|
|
90
|
-
}
|
|
91
|
-
if (!isProjectAllowed(state.currentProjectDir, config)) return false;
|
|
92
|
-
if (!fs.existsSync(state.currentSessionFile)) {
|
|
93
|
-
if (!silent) ctx.ui.notify(`Session file missing: ${state.currentSessionFile}`, "warning");
|
|
94
|
-
return false;
|
|
95
|
-
}
|
|
96
|
-
try {
|
|
97
|
-
await ensureWebdavDirectory(`${WEBDAV_SESSIONS_DIR}${state.currentProjectDir}/`, config, ctx);
|
|
98
|
-
const remote = sessionsWebdavBase(config) + encodeURIComponent(state.currentProjectDir) + "/" + encodeURIComponent(path.basename(state.currentSessionFile));
|
|
99
|
-
await webdavPutFile(state.currentSessionFile, remote, webdavAuth(config), ctx);
|
|
100
|
-
await updateLatestMarker(ctx, config, state.currentProjectDir, state.currentSessionFile);
|
|
101
|
-
if (!silent) ctx.ui.notify(`☁️ Session uploaded: ${path.basename(state.currentSessionFile)}`, "info");
|
|
102
|
-
return true;
|
|
103
|
-
} catch (error) {
|
|
104
|
-
if (!silent) ctx.ui.notify(`❌ Session upload failed: ${error instanceof Error ? error.message : String(error)}`, "error");
|
|
105
|
-
return false;
|
|
106
|
-
}
|
|
107
|
-
}
|
|
108
|
-
|
|
109
|
-
export function scheduleLiveBackup(ctx: ExtensionContext): void {
|
|
110
|
-
const config = loadConfig();
|
|
111
|
-
if (!config.liveSessionBackup || !currentProjectIsAllowed(config)) return;
|
|
112
|
-
clearLiveBackupTimer();
|
|
113
|
-
state.liveBackupTimer = setTimeout(() => {
|
|
114
|
-
state.liveBackupTimer = undefined;
|
|
115
|
-
uploadCurrentSession(ctx, true).catch(() => { /* silent */ });
|
|
116
|
-
}, config.liveBackupDebounceMs);
|
|
117
|
-
}
|
|
118
|
-
|
|
119
|
-
export function clearLiveBackupTimer(): void {
|
|
120
|
-
if (state.liveBackupTimer) clearTimeout(state.liveBackupTimer);
|
|
121
|
-
state.liveBackupTimer = undefined;
|
|
122
|
-
}
|
|
123
|
-
|
|
124
|
-
async function listRemoteProjects(config: SyncConfig, ctx: ExtensionContext): Promise<string[]> {
|
|
125
|
-
try { return await webdavList(sessionsWebdavBase(config), webdavAuth(config), ctx, (name) => name.startsWith("--") && name.endsWith("--")); }
|
|
126
|
-
catch (error) { if (error instanceof Error && /HTTP 404/.test(error.message)) return []; throw error; }
|
|
127
|
-
}
|
|
128
|
-
|
|
129
|
-
export async function showRestoreLatest(ctx: ExtensionCommandContext): Promise<void> {
|
|
130
|
-
const config = loadConfig();
|
|
131
|
-
try {
|
|
132
|
-
const projects = await listRemoteProjects(config, ctx);
|
|
133
|
-
if (!projects.length) { ctx.ui.notify("No remote session projects found.", "warning"); return; }
|
|
134
|
-
const project = await enhancedSelect(ctx, "Restore latest: select project", [...projects, "❌ Cancel"], { fuzzy: true });
|
|
135
|
-
if (!project || project.includes("Cancel")) return;
|
|
136
|
-
const markerPath = path.join(os.tmpdir(), `pi_latest_${Date.now()}.json`);
|
|
137
|
-
try {
|
|
138
|
-
const base = sessionsWebdavBase(config) + encodeURIComponent(project) + "/";
|
|
139
|
-
await webdavGetFile(base + "_latest.json", markerPath, webdavAuth(config), ctx);
|
|
140
|
-
const marker = readJsonSafe<{ file?: string }>(markerPath, {});
|
|
141
|
-
if (!marker.file || !marker.file.endsWith(".jsonl") || path.basename(marker.file) !== marker.file) throw new Error("Remote _latest.json has no safe session filename.");
|
|
142
|
-
const localDir = path.join(SESSIONS_DIR, project); ensureDir(localDir);
|
|
143
|
-
const localPath = path.join(localDir, marker.file);
|
|
144
|
-
await webdavGetFile(base + encodeURIComponent(marker.file), localPath, webdavAuth(config), ctx);
|
|
145
|
-
ctx.ui.notify(`🎉 Restored latest session into ${localPath}\nUse /resume to continue.`, "info");
|
|
146
|
-
} finally { fs.rmSync(markerPath, { force: true }); }
|
|
147
|
-
} catch (error) { ctx.ui.notify(`❌ Restore latest failed: ${error instanceof Error ? error.message : String(error)}`, "error"); }
|
|
148
|
-
}
|
|
149
|
-
|
|
150
|
-
export async function showRestoreSessions(ctx: ExtensionCommandContext): Promise<void> {
|
|
151
|
-
const config = loadConfig();
|
|
152
|
-
try {
|
|
153
|
-
const projects = await listRemoteProjects(config, ctx);
|
|
154
|
-
if (!projects.length) { ctx.ui.notify("No remote session projects found.", "warning"); return; }
|
|
155
|
-
const project = await enhancedSelect(ctx, "Select remote project to restore", [...projects, "❌ Cancel"], { fuzzy: true });
|
|
156
|
-
if (!project || project.includes("Cancel")) return;
|
|
157
|
-
const base = sessionsWebdavBase(config) + encodeURIComponent(project) + "/";
|
|
158
|
-
const files = await webdavList(base, webdavAuth(config), ctx, (name) => name.endsWith(".jsonl"));
|
|
159
|
-
if (!files.length) { ctx.ui.notify(`No .jsonl files in ${project}.`, "warning"); return; }
|
|
160
|
-
const choice = await enhancedSelect(ctx, `Restore from ${project}`, [...files, "a Restore ALL", "❌ Cancel"], { fuzzy: true });
|
|
161
|
-
if (!choice || choice.includes("Cancel")) return;
|
|
162
|
-
const targets = choice === "a Restore ALL" ? files : [choice];
|
|
163
|
-
const localDir = path.join(SESSIONS_DIR, project); ensureDir(localDir);
|
|
164
|
-
for (const file of targets) await webdavGetFile(base + encodeURIComponent(file), path.join(localDir, file), webdavAuth(config), ctx);
|
|
165
|
-
ctx.ui.notify(`🎉 Restored ${targets.length} session(s) into ${localDir}`, "info");
|
|
166
|
-
} catch (error) { ctx.ui.notify(`❌ Restore failed: ${error instanceof Error ? error.message : String(error)}`, "error"); }
|
|
167
|
-
}
|
|
168
|
-
|
|
169
|
-
export async function showForkSession(ctx: ExtensionCommandContext): Promise<void> {
|
|
170
|
-
const config = loadConfig();
|
|
171
|
-
try {
|
|
172
|
-
const projects = await listRemoteProjects(config, ctx);
|
|
173
|
-
if (!projects.length) { ctx.ui.notify("No remote session projects found.", "warning"); return; }
|
|
174
|
-
const project = await enhancedSelect(ctx, "Fork: select source project", [...projects, "❌ Cancel"], { fuzzy: true });
|
|
175
|
-
if (!project || project.includes("Cancel")) return;
|
|
176
|
-
const base = sessionsWebdavBase(config) + encodeURIComponent(project) + "/";
|
|
177
|
-
const files = await webdavList(base, webdavAuth(config), ctx, (name) => name.endsWith(".jsonl"));
|
|
178
|
-
const file = await enhancedSelect(ctx, "Fork: select session", [...files, "❌ Cancel"], { fuzzy: true });
|
|
179
|
-
if (!file || file.includes("Cancel")) return;
|
|
180
|
-
const temp = path.join(os.tmpdir(), `pi_fork_${Date.now()}.jsonl`);
|
|
181
|
-
try {
|
|
182
|
-
await webdavGetFile(base + encodeURIComponent(file), temp, webdavAuth(config), ctx);
|
|
183
|
-
const manager = SessionManager.forkFrom(temp, process.cwd());
|
|
184
|
-
ctx.ui.notify(`🎉 Forked into: ${manager.getSessionFile() ?? "(unknown)"}`, "info");
|
|
185
|
-
} finally { fs.rmSync(temp, { force: true }); }
|
|
186
|
-
} catch (error) { ctx.ui.notify(`❌ Fork failed: ${error instanceof Error ? error.message : String(error)}`, "error"); }
|
|
187
|
-
}
|
|
188
|
-
|
|
189
|
-
export async function showSessionProjectSelect(ctx: ExtensionCommandContext, config: SyncConfig): Promise<void> {
|
|
190
|
-
while (true) {
|
|
191
|
-
const projects = listSessionProjects();
|
|
192
|
-
if (!projects.length) { ctx.ui.notify("No local session projects found.", "warning"); return; }
|
|
193
|
-
const selected = new Set(config.sessionProjects);
|
|
194
|
-
const items = projects.map((dir) => `${selected.has(dir) ? "[x]" : "[ ]"} ${sessionDirToPath(dir)}`);
|
|
195
|
-
items.push("───────────────", `m Switch to ${config.sessionProjectMode === "whitelist" ? "blacklist" : "whitelist"} mode`, "a Select All", "r Reset list", "x Back");
|
|
196
|
-
const label = config.sessionProjectMode === "whitelist" ? "白名单模式" : "黑名单模式";
|
|
197
|
-
const choice = await enhancedSelect(ctx, `Select Session Projects [${label}]`, items, { fuzzy: true });
|
|
198
|
-
if (!choice || choice === "x Back") { saveConfig(config, ctx); return; }
|
|
199
|
-
if (choice.startsWith("m Switch")) { config.sessionProjectMode = config.sessionProjectMode === "whitelist" ? "blacklist" : "whitelist"; saveConfig(config, ctx); continue; }
|
|
200
|
-
if (choice === "a Select All") { config.sessionProjects = [...projects]; saveConfig(config, ctx); continue; }
|
|
201
|
-
if (choice.startsWith("r Reset")) { config.sessionProjects = []; saveConfig(config, ctx); continue; }
|
|
202
|
-
const match = choice.match(/^\[[ x]\]\s+(.*)$/); if (!match) continue;
|
|
203
|
-
const dir = projects.find((candidate) => sessionDirToPath(candidate) === match[1]); if (!dir) continue;
|
|
204
|
-
config.sessionProjects = selected.has(dir) ? config.sessionProjects.filter((item) => item !== dir) : [...config.sessionProjects, dir];
|
|
205
|
-
saveConfig(config, ctx);
|
|
206
|
-
}
|
|
207
|
-
}
|
|
208
|
-
|
|
209
|
-
export async function showSessionSyncMenu(ctx: ExtensionCommandContext): Promise<void> {
|
|
210
|
-
while (true) {
|
|
211
|
-
const config = loadConfig();
|
|
212
|
-
const current = state.currentSessionFile ? path.basename(state.currentSessionFile) : "(none)";
|
|
213
|
-
const choice = await enhancedSelect(ctx, "Session Sync", [
|
|
214
|
-
`⚡ Live Backup: ${config.liveSessionBackup ? "ON" : "OFF"}`,
|
|
215
|
-
` ↳ Debounce: ${config.liveBackupDebounceMs}ms`,
|
|
216
|
-
`🔄 Sync Interval: ${config.syncIntervalTurns === 0 ? "OFF" : `every ${config.syncIntervalTurns} turns`}`,
|
|
217
|
-
`📤 Sync On Exit: ${config.syncSessionOnExit ? "ON" : "OFF"}`,
|
|
218
|
-
`☁️ Upload Current Session Now (cur: ${current})`,
|
|
219
|
-
"📥 Restore Latest Session", "📥 Restore Sessions", "🌿 Fork Remote Session", "x Back",
|
|
220
|
-
]);
|
|
221
|
-
if (!choice || choice === "x Back") return;
|
|
222
|
-
if (choice.startsWith("⚡")) { config.liveSessionBackup = !config.liveSessionBackup; saveConfig(config, ctx); continue; }
|
|
223
|
-
if (choice.startsWith(" ↳")) { const value = await ctx.ui.input("Debounce ms:", String(config.liveBackupDebounceMs)); const n = value ? parseInt(value, 10) : NaN; if (n > 0) { config.liveBackupDebounceMs = n; saveConfig(config, ctx); } continue; }
|
|
224
|
-
if (choice.startsWith("🔄")) { const value = await ctx.ui.input("Upload every N turns (0 = off):", String(config.syncIntervalTurns)); const n = value ? parseInt(value, 10) : NaN; if (n >= 0) { config.syncIntervalTurns = n; saveConfig(config, ctx); } continue; }
|
|
225
|
-
if (choice.startsWith("📤")) { config.syncSessionOnExit = !config.syncSessionOnExit; saveConfig(config, ctx); continue; }
|
|
226
|
-
if (choice.startsWith("☁️")) { await uploadCurrentSession(ctx); continue; }
|
|
227
|
-
if (choice.startsWith("📥 Restore Latest")) { await showRestoreLatest(ctx); continue; }
|
|
228
|
-
if (choice.startsWith("📥 Restore Sessions")) { await showRestoreSessions(ctx); continue; }
|
|
229
|
-
if (choice.startsWith("🌿")) { await showForkSession(ctx); continue; }
|
|
230
|
-
}
|
|
231
|
-
}
|