devpulse-ai 0.1.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.
@@ -0,0 +1,14 @@
1
+ import { claudeCodeAdapter } from "./claude-code.js";
2
+ import { codexAdapter } from "./codex.js";
3
+ import { openclawAdapter } from "./openclaw.js";
4
+ import { cursorAdapter } from "./cursor.js";
5
+ /** All supported tool adapters, in display order. */
6
+ export const adapters = [
7
+ claudeCodeAdapter,
8
+ codexAdapter,
9
+ openclawAdapter,
10
+ cursorAdapter,
11
+ ];
12
+ export function getAdapter(tool) {
13
+ return adapters.find((a) => a.tool === tool);
14
+ }
@@ -0,0 +1,167 @@
1
+ import { homedir } from "node:os";
2
+ import { join, basename } from "node:path";
3
+ import { createHash } from "node:crypto";
4
+ import { existsSync, readdirSync, readFileSync, statSync } from "node:fs";
5
+ import { buildSessionSummary } from "../summary.js";
6
+ import { buildSummaryNotes, cleanUserText } from "../session-notes.js";
7
+ const TOOL = "openclaw";
8
+ /** Root of OpenClaw's per-agent session transcripts. */
9
+ function openclawAgentsDir() {
10
+ return process.env.OPENCLAW_HOME
11
+ ? join(process.env.OPENCLAW_HOME, "agents")
12
+ : join(homedir(), ".openclaw", "agents");
13
+ }
14
+ /** Active session transcripts: `<agents>/<agent>/sessions/*.jsonl`.
15
+ * Archived files (`*.jsonl.reset.*`, `*.jsonl.deleted.*`) are skipped. */
16
+ function listOpenclawSessions() {
17
+ const root = openclawAgentsDir();
18
+ if (!existsSync(root))
19
+ return [];
20
+ const out = [];
21
+ for (const agent of readdirSync(root, { withFileTypes: true })) {
22
+ if (!agent.isDirectory())
23
+ continue;
24
+ const sessionsDir = join(root, agent.name, "sessions");
25
+ if (!existsSync(sessionsDir))
26
+ continue;
27
+ for (const entry of readdirSync(sessionsDir, { withFileTypes: true })) {
28
+ if (entry.isFile() && entry.name.endsWith(".jsonl")) {
29
+ out.push(join(sessionsDir, entry.name));
30
+ }
31
+ }
32
+ }
33
+ return out;
34
+ }
35
+ function textFromContent(content) {
36
+ if (!content)
37
+ return null;
38
+ if (typeof content === "string")
39
+ return content;
40
+ if (Array.isArray(content)) {
41
+ for (const part of content) {
42
+ if (part && typeof part === "object") {
43
+ const p = part;
44
+ if (p.text)
45
+ return p.text;
46
+ }
47
+ }
48
+ }
49
+ return null;
50
+ }
51
+ function mostFrequent(values) {
52
+ if (values.length === 0)
53
+ return null;
54
+ const counts = new Map();
55
+ for (const v of values)
56
+ counts.set(v, (counts.get(v) ?? 0) + 1);
57
+ return [...counts.entries()].sort((a, b) => b[1] - a[1])[0][0];
58
+ }
59
+ function parseOpenclawSession(filePath) {
60
+ const lines = readFileSync(filePath, "utf8").split("\n");
61
+ let sessionId = null;
62
+ let cwd = null;
63
+ const userMessages = [];
64
+ let messageCount = 0;
65
+ let inputTokens = 0;
66
+ let outputTokens = 0;
67
+ let cacheReadTokens = 0;
68
+ let cacheCreationTokens = 0;
69
+ const models = [];
70
+ const timestamps = [];
71
+ for (const line of lines) {
72
+ const trimmed = line.trim();
73
+ if (!trimmed)
74
+ continue;
75
+ let entry;
76
+ try {
77
+ entry = JSON.parse(trimmed);
78
+ }
79
+ catch {
80
+ continue;
81
+ }
82
+ if (entry.type === "session") {
83
+ if (entry.id && !sessionId)
84
+ sessionId = entry.id;
85
+ if (entry.cwd && !cwd)
86
+ cwd = entry.cwd;
87
+ }
88
+ if (entry.type === "model_change" && entry.modelId)
89
+ models.push(entry.modelId);
90
+ if (entry.timestamp) {
91
+ const t = Date.parse(entry.timestamp);
92
+ if (!Number.isNaN(t))
93
+ timestamps.push(t);
94
+ }
95
+ const msg = entry.message;
96
+ if (entry.type === "message" && msg) {
97
+ messageCount++;
98
+ if (msg.role === "user") {
99
+ const text = textFromContent(msg.content);
100
+ if (text) {
101
+ const cleaned = cleanUserText(text);
102
+ if (cleaned)
103
+ userMessages.push(cleaned);
104
+ }
105
+ }
106
+ if (msg.model)
107
+ models.push(msg.model);
108
+ const u = msg.usage;
109
+ if (u) {
110
+ inputTokens += u.input ?? 0;
111
+ outputTokens += u.output ?? 0;
112
+ cacheReadTokens += u.cacheRead ?? 0;
113
+ cacheCreationTokens += u.cacheWrite ?? 0;
114
+ }
115
+ }
116
+ }
117
+ const rawId = sessionId ?? basename(filePath, ".jsonl");
118
+ if (messageCount === 0 && timestamps.length === 0)
119
+ return null;
120
+ const projectName = cwd ? basename(cwd) : null;
121
+ const projectPathHash = cwd ? createHash("sha256").update(cwd).digest("hex").slice(0, 32) : null;
122
+ const firstUserText = userMessages[0] ?? null;
123
+ const summaryNotes = buildSummaryNotes({
124
+ tool: TOOL,
125
+ projectName,
126
+ userMessages,
127
+ });
128
+ return {
129
+ externalId: `${TOOL}:${rawId}`,
130
+ tool: TOOL,
131
+ model: mostFrequent(models),
132
+ projectPathHash,
133
+ projectName,
134
+ summary: buildSessionSummary({ firstUserText, explicitSummary: null, projectName, messageCount }),
135
+ summaryNotes,
136
+ messageCount,
137
+ inputTokens,
138
+ outputTokens,
139
+ cacheReadTokens,
140
+ cacheCreationTokens,
141
+ startedAt: timestamps.length ? new Date(Math.min(...timestamps)).toISOString() : null,
142
+ endedAt: timestamps.length ? new Date(Math.max(...timestamps)).toISOString() : null,
143
+ };
144
+ }
145
+ export const openclawAdapter = {
146
+ tool: TOOL,
147
+ label: "OpenClaw",
148
+ available() {
149
+ return existsSync(openclawAgentsDir());
150
+ },
151
+ discover() {
152
+ return listOpenclawSessions().map((file) => ({
153
+ stateKey: `${TOOL}:${file}`,
154
+ fingerprint: safeFingerprint(file),
155
+ load: () => parseOpenclawSession(file),
156
+ }));
157
+ },
158
+ };
159
+ function safeFingerprint(file) {
160
+ try {
161
+ const st = statSync(file);
162
+ return `${Math.round(st.mtimeMs)}:${st.size}`;
163
+ }
164
+ catch {
165
+ return "";
166
+ }
167
+ }
@@ -0,0 +1 @@
1
+ export {};
package/dist/api.js ADDED
@@ -0,0 +1,41 @@
1
+ class ApiError extends Error {
2
+ status;
3
+ constructor(status, message) {
4
+ super(message);
5
+ this.status = status;
6
+ }
7
+ }
8
+ async function request(config, path, body) {
9
+ if (!config.token)
10
+ throw new ApiError(401, "Not logged in. Run `devpulse login` first.");
11
+ const res = await fetch(new URL(path, config.apiUrl), {
12
+ method: "POST",
13
+ headers: {
14
+ "content-type": "application/json",
15
+ authorization: `Bearer ${config.token}`,
16
+ },
17
+ body: JSON.stringify(body),
18
+ });
19
+ if (!res.ok) {
20
+ let detail = res.statusText;
21
+ try {
22
+ const json = (await res.json());
23
+ if (json.error)
24
+ detail = json.error;
25
+ if (json.details)
26
+ detail += `: ${JSON.stringify(json.details)}`;
27
+ }
28
+ catch {
29
+ /* ignore */
30
+ }
31
+ throw new ApiError(res.status, detail);
32
+ }
33
+ return (await res.json());
34
+ }
35
+ export function verifyToken(config) {
36
+ return request(config, "/api/cli/verify", {});
37
+ }
38
+ export function uploadSessions(config, sessions) {
39
+ return request(config, "/api/cli/sync", { sessions });
40
+ }
41
+ export { ApiError };
@@ -0,0 +1,101 @@
1
+ import { randomBytes } from "node:crypto";
2
+ import { execFile } from "node:child_process";
3
+ import http from "node:http";
4
+ import os from "node:os";
5
+ import { promisify } from "node:util";
6
+ const execFileAsync = promisify(execFile);
7
+ const TIMEOUT_MS = 5 * 60_000;
8
+ async function openBrowser(url) {
9
+ if (process.platform === "darwin") {
10
+ await execFileAsync("open", [url]);
11
+ return;
12
+ }
13
+ if (process.platform === "win32") {
14
+ await execFileAsync("cmd", ["/c", "start", "", url], { windowsHide: true });
15
+ return;
16
+ }
17
+ await execFileAsync("xdg-open", [url]);
18
+ }
19
+ function successHtml() {
20
+ return `<!DOCTYPE html>
21
+ <html lang="en">
22
+ <head><meta charset="utf-8"><title>DevPulse CLI</title></head>
23
+ <body style="font-family: system-ui, sans-serif; max-width: 32rem; margin: 4rem auto; padding: 0 1rem;">
24
+ <h1>DevPulse CLI connected</h1>
25
+ <p>You can close this tab and return to the terminal.</p>
26
+ </body>
27
+ </html>`;
28
+ }
29
+ function errorHtml(message) {
30
+ return `<!DOCTYPE html>
31
+ <html lang="en">
32
+ <head><meta charset="utf-8"><title>DevPulse CLI</title></head>
33
+ <body style="font-family: system-ui, sans-serif; max-width: 32rem; margin: 4rem auto; padding: 0 1rem;">
34
+ <h1>Authorization failed</h1>
35
+ <p>${message}</p>
36
+ <p>Close this tab and run <code>devpulse login</code> again in the terminal.</p>
37
+ </body>
38
+ </html>`;
39
+ }
40
+ /** Start a localhost callback server, open the dashboard authorize page, wait for the token. */
41
+ export function waitForBrowserAuth(apiUrl, onReady) {
42
+ const state = randomBytes(16).toString("hex");
43
+ const hostname = os.hostname();
44
+ return new Promise((resolve, reject) => {
45
+ let settled = false;
46
+ let timer;
47
+ function finish(err, result) {
48
+ if (settled)
49
+ return;
50
+ settled = true;
51
+ if (timer)
52
+ clearTimeout(timer);
53
+ server.close();
54
+ if (err)
55
+ reject(err);
56
+ else
57
+ resolve(result);
58
+ }
59
+ const server = http.createServer((req, res) => {
60
+ if (!req.url?.startsWith("/callback")) {
61
+ res.writeHead(404);
62
+ res.end("Not found");
63
+ return;
64
+ }
65
+ const url = new URL(req.url, "http://127.0.0.1");
66
+ const token = url.searchParams.get("token");
67
+ const returnedState = url.searchParams.get("state");
68
+ if (!token || returnedState !== state) {
69
+ res.writeHead(400, { "Content-Type": "text/html; charset=utf-8" });
70
+ res.end(errorHtml("Invalid or missing authorization parameters."));
71
+ finish(new Error("Authorization callback rejected."));
72
+ return;
73
+ }
74
+ res.writeHead(200, { "Content-Type": "text/html; charset=utf-8" });
75
+ res.end(successHtml());
76
+ finish(null, { token });
77
+ });
78
+ server.on("error", (err) => finish(err));
79
+ server.listen(0, "127.0.0.1", () => {
80
+ const addr = server.address();
81
+ if (!addr || typeof addr === "string") {
82
+ finish(new Error("Could not start local callback server."));
83
+ return;
84
+ }
85
+ const port = addr.port;
86
+ const params = new URLSearchParams({
87
+ state,
88
+ port: String(port),
89
+ hostname,
90
+ });
91
+ const authUrl = `${apiUrl.replace(/\/$/, "")}/cli/authorize?${params}`;
92
+ onReady?.(authUrl);
93
+ openBrowser(authUrl).catch(() => {
94
+ // Browser open is best-effort; login prints the URL if needed.
95
+ });
96
+ timer = setTimeout(() => {
97
+ finish(new Error("Login timed out after 5 minutes. Run `devpulse login` again."));
98
+ }, TIMEOUT_MS);
99
+ });
100
+ });
101
+ }
@@ -0,0 +1,60 @@
1
+ import { loadConfig, saveConfig } from "../config.js";
2
+ import { verifyToken, ApiError } from "../api.js";
3
+ import { waitForBrowserAuth } from "../browser-auth.js";
4
+ import { ui, prompt } from "../ui.js";
5
+ async function loginWithBrowser(apiUrl) {
6
+ ui.info("Opening browser to log in…");
7
+ let authUrl = "";
8
+ const resultPromise = waitForBrowserAuth(apiUrl, (url) => {
9
+ authUrl = url;
10
+ });
11
+ // Give the local server a moment to bind and produce the URL.
12
+ await new Promise((r) => setTimeout(r, 150));
13
+ if (authUrl) {
14
+ ui.dim(`If the browser didn't open, visit:\n ${authUrl}`);
15
+ }
16
+ const { token } = await resultPromise;
17
+ return token;
18
+ }
19
+ export async function login(opts) {
20
+ const config = loadConfig();
21
+ if (opts.apiUrl)
22
+ config.apiUrl = opts.apiUrl.replace(/\/$/, "");
23
+ ui.dim(`Dashboard: ${config.apiUrl}`);
24
+ let token = opts.token?.trim();
25
+ if (!token && opts.browser !== false) {
26
+ try {
27
+ token = (await loginWithBrowser(config.apiUrl)) ?? undefined;
28
+ }
29
+ catch (err) {
30
+ ui.warn(`Browser login failed: ${err.message}`);
31
+ ui.dim("You can paste a token from Settings instead.");
32
+ }
33
+ }
34
+ if (!token) {
35
+ token = await prompt(`Paste a CLI token (create one at ${config.apiUrl}/dashboard/settings):\n> `);
36
+ }
37
+ if (!token) {
38
+ ui.error("No token provided.");
39
+ process.exitCode = 1;
40
+ return;
41
+ }
42
+ config.token = token.trim();
43
+ try {
44
+ const res = await verifyToken(config);
45
+ config.user = res.user;
46
+ config.team = res.team;
47
+ saveConfig(config);
48
+ ui.success(`Logged in as ${res.user.name ?? res.user.email ?? res.user.id} → team "${res.team.name ?? res.team.id}".`);
49
+ ui.dim("Run `devpulse sync` to upload your AI coding sessions.");
50
+ }
51
+ catch (err) {
52
+ if (err instanceof ApiError && err.status === 401) {
53
+ ui.error("Token rejected. Double-check it was copied correctly and isn't revoked.");
54
+ }
55
+ else {
56
+ ui.error(`Login failed: ${err.message}`);
57
+ }
58
+ process.exitCode = 1;
59
+ }
60
+ }
@@ -0,0 +1,95 @@
1
+ import { existsSync } from "node:fs";
2
+ import { loadConfig } from "../config.js";
3
+ import { describeSchedule, installSchedule, removeSchedule } from "../schedule/install.js";
4
+ import { clearScheduleConfig, loadScheduleConfig, saveScheduleConfig, SCHEDULE_LOG_PATH, } from "../schedule/store.js";
5
+ import { ui } from "../ui.js";
6
+ function parseIntervalHours(raw) {
7
+ const n = parseInt(raw, 10);
8
+ if (!Number.isFinite(n) || n < 1 || n > 24) {
9
+ throw new Error("--every must be an integer between 1 and 24 (hours).");
10
+ }
11
+ return n;
12
+ }
13
+ function parseDailyAt(raw) {
14
+ const value = raw ?? "09:00";
15
+ const m = /^(\d{1,2}):(\d{2})$/.exec(value);
16
+ if (!m)
17
+ throw new Error("Invalid --at time; use HH:MM in 24-hour format, e.g. 09:00.");
18
+ const hour = parseInt(m[1], 10);
19
+ const minute = parseInt(m[2], 10);
20
+ if (hour < 0 || hour > 23 || minute < 0 || minute > 59) {
21
+ throw new Error("Invalid --at time; hour must be 0–23 and minute 0–59.");
22
+ }
23
+ return `${String(hour).padStart(2, "0")}:${String(minute).padStart(2, "0")}`;
24
+ }
25
+ function requireLogin() {
26
+ const config = loadConfig();
27
+ if (!config.token) {
28
+ ui.error("Not logged in. Run `devpulse login` before scheduling automatic sync.");
29
+ process.exitCode = 1;
30
+ return false;
31
+ }
32
+ return true;
33
+ }
34
+ export async function scheduleInstall(opts) {
35
+ if (!requireLogin())
36
+ return;
37
+ if (opts.every && opts.daily) {
38
+ ui.error("Use either --every <hours> or --daily, not both.");
39
+ process.exitCode = 1;
40
+ return;
41
+ }
42
+ if (!opts.every && !opts.daily) {
43
+ ui.error("Choose a schedule: --every <hours> or --daily [--at HH:MM].");
44
+ process.exitCode = 1;
45
+ return;
46
+ }
47
+ try {
48
+ const existing = loadScheduleConfig();
49
+ if (existing)
50
+ removeSchedule(existing);
51
+ const spec = opts.every
52
+ ? { mode: "interval", intervalHours: parseIntervalHours(opts.every) }
53
+ : { mode: "daily", dailyAt: parseDailyAt(opts.at) };
54
+ const config = installSchedule(spec);
55
+ saveScheduleConfig(config);
56
+ ui.success(`Automatic sync scheduled (${describeSchedule(config)}).`);
57
+ ui.dim(`Runs \`devpulse sync\` in the background via ${config.platform}.`);
58
+ ui.dim(`Logs: ${SCHEDULE_LOG_PATH}`);
59
+ ui.info("Run `devpulse schedule status` to inspect, or `devpulse schedule remove` to uninstall.");
60
+ }
61
+ catch (err) {
62
+ ui.error(err instanceof Error ? err.message : String(err));
63
+ process.exitCode = 1;
64
+ }
65
+ }
66
+ export async function scheduleStatus() {
67
+ const config = loadScheduleConfig();
68
+ ui.heading("DevPulse schedule");
69
+ if (!config) {
70
+ ui.warn("No automatic sync is installed.");
71
+ ui.dim("Install with `devpulse schedule install --every 6` or `devpulse schedule install --daily --at 09:00`.");
72
+ return;
73
+ }
74
+ ui.success(`Installed: ${describeSchedule(config)}`);
75
+ ui.info(` Platform: ${config.platform}`);
76
+ ui.info(` CLI path: ${config.cliPath}`);
77
+ ui.info(` Installed: ${new Date(config.installedAt).toLocaleString()}`);
78
+ ui.info(` Log file: ${SCHEDULE_LOG_PATH}${existsSync(SCHEDULE_LOG_PATH) ? "" : " (not created yet)"}`);
79
+ }
80
+ export async function scheduleRemove() {
81
+ const config = loadScheduleConfig();
82
+ if (!config) {
83
+ ui.warn("No automatic sync is installed.");
84
+ return;
85
+ }
86
+ try {
87
+ removeSchedule(config);
88
+ clearScheduleConfig();
89
+ ui.success("Automatic sync removed.");
90
+ }
91
+ catch (err) {
92
+ ui.error(err instanceof Error ? err.message : String(err));
93
+ process.exitCode = 1;
94
+ }
95
+ }
@@ -0,0 +1,54 @@
1
+ import { loadConfig, loadState, paths } from "../config.js";
2
+ import { adapters, getAdapter } from "../adapters/index.js";
3
+ import { ui } from "../ui.js";
4
+ export async function status(opts = {}) {
5
+ const config = loadConfig();
6
+ const state = loadState();
7
+ ui.heading("DevPulse status");
8
+ ui.info(` Dashboard: ${config.apiUrl}`);
9
+ ui.info(` Config file: ${paths.CONFIG_PATH}`);
10
+ if (config.token) {
11
+ const who = config.user?.name ?? config.user?.email ?? config.user?.id ?? "unknown";
12
+ ui.success(` Logged in as ${who} → team "${config.team?.name ?? config.team?.id ?? "?"}"`);
13
+ }
14
+ else {
15
+ ui.warn(" Not logged in. Run `devpulse login`.");
16
+ }
17
+ let active = adapters;
18
+ if (opts.tool) {
19
+ const one = getAdapter(opts.tool);
20
+ if (!one) {
21
+ ui.error(`\n Unknown tool "${opts.tool}". Supported: ${adapters.map((a) => a.tool).join(", ")}.`);
22
+ process.exitCode = 1;
23
+ return;
24
+ }
25
+ active = [one];
26
+ }
27
+ ui.info("\n Tools:");
28
+ let totalPending = 0;
29
+ for (const adapter of active) {
30
+ if (!adapter.available({ dir: opts.dir })) {
31
+ ui.dim(` ${adapter.label}: not detected`);
32
+ continue;
33
+ }
34
+ const discovered = adapter.discover({ dir: opts.dir });
35
+ let pending = 0;
36
+ for (const ds of discovered) {
37
+ const prev = state.sessions[ds.stateKey];
38
+ if (!prev || prev.fingerprint !== ds.fingerprint)
39
+ pending++;
40
+ }
41
+ totalPending += pending;
42
+ ui.info(` ${adapter.label}: ${discovered.length} session(s), ${pending} pending`);
43
+ }
44
+ ui.info(`\n Already synced: ${Object.keys(state.sessions).length}`);
45
+ if (totalPending > 0) {
46
+ ui.warn(` Pending sync: ${totalPending} (run \`devpulse sync\`)`);
47
+ }
48
+ else {
49
+ ui.success(" Pending sync: 0 (up to date)");
50
+ }
51
+ if (state.lastSyncAt) {
52
+ ui.dim(`\n Last sync: ${new Date(state.lastSyncAt).toLocaleString()}`);
53
+ }
54
+ }
@@ -0,0 +1,108 @@
1
+ import { loadConfig, loadState, saveState } from "../config.js";
2
+ import { adapters, getAdapter } from "../adapters/index.js";
3
+ import { uploadSessions, ApiError } from "../api.js";
4
+ import { ui } from "../ui.js";
5
+ const BATCH_SIZE = 200;
6
+ export async function sync(opts) {
7
+ const config = loadConfig();
8
+ if (!config.token) {
9
+ ui.error("Not logged in. Run `devpulse login` first.");
10
+ process.exitCode = 1;
11
+ return;
12
+ }
13
+ // Resolve which tools to scan.
14
+ let active = adapters;
15
+ if (opts.tool) {
16
+ const one = getAdapter(opts.tool);
17
+ if (!one) {
18
+ ui.error(`Unknown tool "${opts.tool}". Supported: ${adapters.map((a) => a.tool).join(", ")}.`);
19
+ process.exitCode = 1;
20
+ return;
21
+ }
22
+ active = [one];
23
+ }
24
+ const available = active.filter((a) => a.available({ dir: opts.dir }));
25
+ if (available.length === 0) {
26
+ ui.warn("No supported AI coding tools found on this machine.");
27
+ ui.dim(`Looked for: ${active.map((a) => a.label).join(", ")}.`);
28
+ return;
29
+ }
30
+ const state = loadState();
31
+ // Discover candidate sessions across every available tool (cheap fingerprints).
32
+ const pending = [];
33
+ let skipped = 0;
34
+ for (const adapter of available) {
35
+ let found = 0;
36
+ const discovered = adapter.discover({ dir: opts.dir });
37
+ for (const ds of discovered) {
38
+ found++;
39
+ const prev = state.sessions[ds.stateKey];
40
+ if (!opts.force && prev && prev.fingerprint === ds.fingerprint) {
41
+ skipped++;
42
+ continue;
43
+ }
44
+ pending.push(ds);
45
+ }
46
+ ui.dim(`Scanned ${adapter.label}: ${found} session(s).`);
47
+ }
48
+ // Parse only the new/changed ones (respecting --limit).
49
+ const toUpload = [];
50
+ for (const ds of pending) {
51
+ let metadata = null;
52
+ try {
53
+ metadata = ds.load();
54
+ }
55
+ catch {
56
+ /* skip unreadable/unparseable session */
57
+ }
58
+ if (metadata)
59
+ toUpload.push({ stateKey: ds.stateKey, fingerprint: ds.fingerprint, metadata });
60
+ if (opts.limit && toUpload.length >= opts.limit)
61
+ break;
62
+ }
63
+ ui.info(`${toUpload.length} new/changed session(s), ${skipped} unchanged (skipped)${opts.force ? " — forced full re-sync" : ""}.`);
64
+ if (toUpload.length === 0) {
65
+ ui.success("Everything is already up to date.");
66
+ return;
67
+ }
68
+ if (opts.dryRun) {
69
+ ui.heading("\nDry run — would upload:");
70
+ for (const { metadata: m } of toUpload.slice(0, 20)) {
71
+ ui.dim(` • [${m.tool}] ${m.projectName ?? "?"} · ${m.model ?? "?"} · ${m.messageCount} msgs · ${m.inputTokens + m.outputTokens} tok`);
72
+ }
73
+ if (toUpload.length > 20)
74
+ ui.dim(` …and ${toUpload.length - 20} more`);
75
+ ui.info("\nNothing uploaded (--dry-run).");
76
+ return;
77
+ }
78
+ let created = 0;
79
+ let updated = 0;
80
+ try {
81
+ for (let i = 0; i < toUpload.length; i += BATCH_SIZE) {
82
+ const batch = toUpload.slice(i, i + BATCH_SIZE);
83
+ const res = await uploadSessions(config, batch.map((b) => b.metadata));
84
+ created += res.created;
85
+ updated += res.updated;
86
+ // Persist fingerprints only after a successful batch upload.
87
+ const now = new Date().toISOString();
88
+ for (const b of batch) {
89
+ state.sessions[b.stateKey] = { fingerprint: b.fingerprint, lastSyncedAt: now };
90
+ }
91
+ saveState(state);
92
+ }
93
+ }
94
+ catch (err) {
95
+ if (err instanceof ApiError && err.status === 401) {
96
+ ui.error("Token rejected during sync. Run `devpulse login` again.");
97
+ }
98
+ else {
99
+ ui.error(`Sync failed: ${err.message}`);
100
+ }
101
+ process.exitCode = 1;
102
+ return;
103
+ }
104
+ state.lastSyncAt = new Date().toISOString();
105
+ saveState(state);
106
+ ui.success(`Synced: ${created} new, ${updated} updated.`);
107
+ ui.dim(`View your team dashboard at ${config.apiUrl}/dashboard`);
108
+ }