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.
- package/README.md +77 -0
- package/dist/adapters/claude-code.js +36 -0
- package/dist/adapters/codex.js +224 -0
- package/dist/adapters/cursor-sources.js +201 -0
- package/dist/adapters/cursor.js +157 -0
- package/dist/adapters/index.js +14 -0
- package/dist/adapters/openclaw.js +167 -0
- package/dist/adapters/types.js +1 -0
- package/dist/api.js +41 -0
- package/dist/browser-auth.js +101 -0
- package/dist/commands/login.js +60 -0
- package/dist/commands/schedule.js +95 -0
- package/dist/commands/status.js +54 -0
- package/dist/commands/sync.js +108 -0
- package/dist/config.js +55 -0
- package/dist/index.js +56 -0
- package/dist/parser/claude-code.js +158 -0
- package/dist/schedule/install.js +165 -0
- package/dist/schedule/store.js +24 -0
- package/dist/session-notes.js +39 -0
- package/dist/summary.js +25 -0
- package/dist/types.js +1 -0
- package/dist/ui.js +21 -0
- package/package.json +46 -0
package/dist/config.js
ADDED
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
import { homedir } from "node:os";
|
|
2
|
+
import { join } from "node:path";
|
|
3
|
+
import { mkdirSync, readFileSync, writeFileSync, existsSync } from "node:fs";
|
|
4
|
+
const CONFIG_DIR = join(homedir(), ".devpulse");
|
|
5
|
+
const CONFIG_PATH = join(CONFIG_DIR, "config.json");
|
|
6
|
+
const STATE_PATH = join(CONFIG_DIR, "state.json");
|
|
7
|
+
/** Public dashboard URL (InsForge Deployments). */
|
|
8
|
+
export const PRODUCTION_API_URL = "https://7aj5nkyd.insforge.site";
|
|
9
|
+
const LEGACY_LOCAL_API_URL = "http://localhost:3000";
|
|
10
|
+
const DEFAULT_API_URL = process.env.DEVPULSE_API_URL?.replace(/\/$/, "") || PRODUCTION_API_URL;
|
|
11
|
+
function resolveApiUrl(stored) {
|
|
12
|
+
if (process.env.DEVPULSE_API_URL) {
|
|
13
|
+
return process.env.DEVPULSE_API_URL.replace(/\/$/, "");
|
|
14
|
+
}
|
|
15
|
+
// Upgrade configs that only ever had the old localhost default.
|
|
16
|
+
if (!stored || stored.replace(/\/$/, "") === LEGACY_LOCAL_API_URL) {
|
|
17
|
+
return DEFAULT_API_URL;
|
|
18
|
+
}
|
|
19
|
+
return stored.replace(/\/$/, "");
|
|
20
|
+
}
|
|
21
|
+
function ensureDir() {
|
|
22
|
+
if (!existsSync(CONFIG_DIR))
|
|
23
|
+
mkdirSync(CONFIG_DIR, { recursive: true, mode: 0o700 });
|
|
24
|
+
}
|
|
25
|
+
export function loadConfig() {
|
|
26
|
+
if (!existsSync(CONFIG_PATH))
|
|
27
|
+
return { apiUrl: DEFAULT_API_URL };
|
|
28
|
+
try {
|
|
29
|
+
const parsed = JSON.parse(readFileSync(CONFIG_PATH, "utf8"));
|
|
30
|
+
return { ...parsed, apiUrl: resolveApiUrl(parsed.apiUrl) };
|
|
31
|
+
}
|
|
32
|
+
catch {
|
|
33
|
+
return { apiUrl: DEFAULT_API_URL };
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
export function saveConfig(config) {
|
|
37
|
+
ensureDir();
|
|
38
|
+
writeFileSync(CONFIG_PATH, JSON.stringify(config, null, 2), { mode: 0o600 });
|
|
39
|
+
}
|
|
40
|
+
export function loadState() {
|
|
41
|
+
if (!existsSync(STATE_PATH))
|
|
42
|
+
return { sessions: {} };
|
|
43
|
+
try {
|
|
44
|
+
const parsed = JSON.parse(readFileSync(STATE_PATH, "utf8"));
|
|
45
|
+
return { sessions: parsed.sessions ?? {}, lastSyncAt: parsed.lastSyncAt };
|
|
46
|
+
}
|
|
47
|
+
catch {
|
|
48
|
+
return { sessions: {} };
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
export function saveState(state) {
|
|
52
|
+
ensureDir();
|
|
53
|
+
writeFileSync(STATE_PATH, JSON.stringify(state, null, 2), { mode: 0o600 });
|
|
54
|
+
}
|
|
55
|
+
export const paths = { CONFIG_DIR, CONFIG_PATH, STATE_PATH };
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import { Command } from "commander";
|
|
3
|
+
import { login } from "./commands/login.js";
|
|
4
|
+
import { sync } from "./commands/sync.js";
|
|
5
|
+
import { status } from "./commands/status.js";
|
|
6
|
+
import { scheduleInstall, scheduleRemove, scheduleStatus } from "./commands/schedule.js";
|
|
7
|
+
import { ui } from "./ui.js";
|
|
8
|
+
const program = new Command();
|
|
9
|
+
program
|
|
10
|
+
.name("devpulse")
|
|
11
|
+
.description("DevPulse AI — sync local AI coding sessions to your team dashboard.")
|
|
12
|
+
.version("0.1.0");
|
|
13
|
+
program
|
|
14
|
+
.command("login")
|
|
15
|
+
.description("Authenticate the CLI (opens your browser by default).")
|
|
16
|
+
.option("--token <token>", "CLI token (skip browser login)")
|
|
17
|
+
.option("--no-browser", "Paste a token manually instead of opening the browser")
|
|
18
|
+
.option("--api-url <url>", "Override the dashboard URL")
|
|
19
|
+
.action(login);
|
|
20
|
+
program
|
|
21
|
+
.command("sync")
|
|
22
|
+
.description("Scan local AI coding tool logs and upload new/changed sessions.")
|
|
23
|
+
.option("--dry-run", "Show what would be uploaded without sending anything")
|
|
24
|
+
.option("--force", "Re-sync all sessions, ignoring local dedupe state")
|
|
25
|
+
.option("--dir <path>", "Override the Claude Code projects directory")
|
|
26
|
+
.option("--tool <name>", "Only sync one tool (claude-code, codex, openclaw, cursor)")
|
|
27
|
+
.option("--limit <n>", "Only process up to N sessions", (v) => parseInt(v, 10))
|
|
28
|
+
.action(sync);
|
|
29
|
+
program
|
|
30
|
+
.command("status")
|
|
31
|
+
.description("Show login state, detected tools, and pending sessions.")
|
|
32
|
+
.option("--dir <path>", "Override the Claude Code projects directory")
|
|
33
|
+
.option("--tool <name>", "Only show one tool (claude-code, codex, openclaw, cursor)")
|
|
34
|
+
.action(status);
|
|
35
|
+
const schedule = program
|
|
36
|
+
.command("schedule")
|
|
37
|
+
.description("Install or manage automatic background sync (macOS launchd / Linux cron).");
|
|
38
|
+
schedule
|
|
39
|
+
.command("install")
|
|
40
|
+
.description("Schedule `devpulse sync` to run automatically.")
|
|
41
|
+
.option("--every <hours>", "Run every N hours (1–24), e.g. --every 6")
|
|
42
|
+
.option("--daily", "Run once per day")
|
|
43
|
+
.option("--at <time>", "With --daily: local time HH:MM (default 09:00)")
|
|
44
|
+
.action(scheduleInstall);
|
|
45
|
+
schedule
|
|
46
|
+
.command("status")
|
|
47
|
+
.description("Show the installed automatic sync schedule.")
|
|
48
|
+
.action(scheduleStatus);
|
|
49
|
+
schedule
|
|
50
|
+
.command("remove")
|
|
51
|
+
.description("Uninstall automatic background sync.")
|
|
52
|
+
.action(scheduleRemove);
|
|
53
|
+
program.parseAsync(process.argv).catch((err) => {
|
|
54
|
+
ui.error(err instanceof Error ? err.message : String(err));
|
|
55
|
+
process.exit(1);
|
|
56
|
+
});
|
|
@@ -0,0 +1,158 @@
|
|
|
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_NAME = "claude-code";
|
|
8
|
+
/** Where Claude Code stores per-project session transcripts. */
|
|
9
|
+
export function claudeProjectsDir() {
|
|
10
|
+
return process.env.CLAUDE_PROJECTS_DIR || join(homedir(), ".claude", "projects");
|
|
11
|
+
}
|
|
12
|
+
export function fingerprintFile(filePath) {
|
|
13
|
+
const st = statSync(filePath);
|
|
14
|
+
return `${Math.round(st.mtimeMs)}:${st.size}`;
|
|
15
|
+
}
|
|
16
|
+
/** Recursively list every `*.jsonl` transcript under the projects directory. */
|
|
17
|
+
export function listSessionFiles(dir = claudeProjectsDir()) {
|
|
18
|
+
if (!existsSync(dir))
|
|
19
|
+
return [];
|
|
20
|
+
const out = [];
|
|
21
|
+
const walk = (d) => {
|
|
22
|
+
for (const entry of readdirSync(d, { withFileTypes: true })) {
|
|
23
|
+
const full = join(d, entry.name);
|
|
24
|
+
if (entry.isDirectory())
|
|
25
|
+
walk(full);
|
|
26
|
+
else if (entry.isFile() && entry.name.endsWith(".jsonl"))
|
|
27
|
+
out.push(full);
|
|
28
|
+
}
|
|
29
|
+
};
|
|
30
|
+
walk(dir);
|
|
31
|
+
return out;
|
|
32
|
+
}
|
|
33
|
+
function textFromContent(content) {
|
|
34
|
+
if (!content)
|
|
35
|
+
return null;
|
|
36
|
+
if (typeof content === "string")
|
|
37
|
+
return content;
|
|
38
|
+
const textPart = content.find((p) => p.type === "text" && p.text);
|
|
39
|
+
return textPart?.text ?? null;
|
|
40
|
+
}
|
|
41
|
+
function mostFrequent(values) {
|
|
42
|
+
if (values.length === 0)
|
|
43
|
+
return null;
|
|
44
|
+
const counts = new Map();
|
|
45
|
+
for (const v of values)
|
|
46
|
+
counts.set(v, (counts.get(v) ?? 0) + 1);
|
|
47
|
+
return [...counts.entries()].sort((a, b) => b[1] - a[1])[0][0];
|
|
48
|
+
}
|
|
49
|
+
/**
|
|
50
|
+
* Parse a single transcript file into one session's metadata. Returns null if
|
|
51
|
+
* the file has no usable entries. Malformed lines are skipped, not fatal.
|
|
52
|
+
*/
|
|
53
|
+
export function parseSessionFile(filePath) {
|
|
54
|
+
const raw = readFileSync(filePath, "utf8");
|
|
55
|
+
const lines = raw.split("\n");
|
|
56
|
+
let sessionId = null;
|
|
57
|
+
let cwd = null;
|
|
58
|
+
let firstUserText = null;
|
|
59
|
+
let explicitSummary = null;
|
|
60
|
+
const userMessages = [];
|
|
61
|
+
let messageCount = 0;
|
|
62
|
+
let inputTokens = 0;
|
|
63
|
+
let outputTokens = 0;
|
|
64
|
+
let cacheReadTokens = 0;
|
|
65
|
+
let cacheCreationTokens = 0;
|
|
66
|
+
const models = [];
|
|
67
|
+
const timestamps = [];
|
|
68
|
+
for (const line of lines) {
|
|
69
|
+
const trimmed = line.trim();
|
|
70
|
+
if (!trimmed)
|
|
71
|
+
continue;
|
|
72
|
+
let entry;
|
|
73
|
+
try {
|
|
74
|
+
entry = JSON.parse(trimmed);
|
|
75
|
+
}
|
|
76
|
+
catch {
|
|
77
|
+
continue; // skip malformed line
|
|
78
|
+
}
|
|
79
|
+
if (entry.sessionId && !sessionId)
|
|
80
|
+
sessionId = entry.sessionId;
|
|
81
|
+
if (entry.cwd && !cwd)
|
|
82
|
+
cwd = entry.cwd;
|
|
83
|
+
if (entry.timestamp) {
|
|
84
|
+
const t = Date.parse(entry.timestamp);
|
|
85
|
+
if (!Number.isNaN(t))
|
|
86
|
+
timestamps.push(t);
|
|
87
|
+
}
|
|
88
|
+
if (entry.type === "summary" && entry.summary) {
|
|
89
|
+
explicitSummary = entry.summary;
|
|
90
|
+
continue;
|
|
91
|
+
}
|
|
92
|
+
const msg = entry.message;
|
|
93
|
+
if (entry.type === "user" && !entry.isMeta && msg) {
|
|
94
|
+
const text = textFromContent(msg.content);
|
|
95
|
+
if (text) {
|
|
96
|
+
const cleaned = cleanUserText(text);
|
|
97
|
+
if (cleaned) {
|
|
98
|
+
userMessages.push(cleaned);
|
|
99
|
+
if (!firstUserText)
|
|
100
|
+
firstUserText = cleaned;
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
messageCount++;
|
|
104
|
+
}
|
|
105
|
+
else if (entry.type === "assistant" && msg) {
|
|
106
|
+
messageCount++;
|
|
107
|
+
if (msg.model)
|
|
108
|
+
models.push(msg.model);
|
|
109
|
+
const u = msg.usage;
|
|
110
|
+
if (u) {
|
|
111
|
+
inputTokens += u.input_tokens ?? 0;
|
|
112
|
+
outputTokens += u.output_tokens ?? 0;
|
|
113
|
+
cacheReadTokens += u.cache_read_input_tokens ?? 0;
|
|
114
|
+
cacheCreationTokens += u.cache_creation_input_tokens ?? 0;
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
// Fall back to the filename (which is the session uuid) for the dedupe key.
|
|
119
|
+
const externalId = sessionId ?? basename(filePath, ".jsonl");
|
|
120
|
+
if (messageCount === 0 && timestamps.length === 0)
|
|
121
|
+
return null;
|
|
122
|
+
const projectName = cwd ? basename(cwd) : decodeProjectDir(filePath);
|
|
123
|
+
const projectPathHash = cwd ? createHash("sha256").update(cwd).digest("hex").slice(0, 32) : null;
|
|
124
|
+
const startedAt = timestamps.length ? new Date(Math.min(...timestamps)).toISOString() : null;
|
|
125
|
+
const endedAt = timestamps.length ? new Date(Math.max(...timestamps)).toISOString() : null;
|
|
126
|
+
const summaryNotes = buildSummaryNotes({
|
|
127
|
+
tool: TOOL_NAME,
|
|
128
|
+
projectName,
|
|
129
|
+
title: explicitSummary,
|
|
130
|
+
userMessages,
|
|
131
|
+
});
|
|
132
|
+
const metadata = {
|
|
133
|
+
externalId,
|
|
134
|
+
tool: TOOL_NAME,
|
|
135
|
+
model: mostFrequent(models),
|
|
136
|
+
projectPathHash,
|
|
137
|
+
projectName,
|
|
138
|
+
summary: buildSessionSummary({ firstUserText, explicitSummary, projectName, messageCount }),
|
|
139
|
+
summaryNotes,
|
|
140
|
+
messageCount,
|
|
141
|
+
inputTokens,
|
|
142
|
+
outputTokens,
|
|
143
|
+
cacheReadTokens,
|
|
144
|
+
cacheCreationTokens,
|
|
145
|
+
startedAt,
|
|
146
|
+
endedAt,
|
|
147
|
+
};
|
|
148
|
+
return { metadata, filePath, fingerprint: fingerprintFile(filePath) };
|
|
149
|
+
}
|
|
150
|
+
/** Claude Code encodes the project path into the directory name (slashes -> dashes). */
|
|
151
|
+
function decodeProjectDir(filePath) {
|
|
152
|
+
const parts = filePath.split("/");
|
|
153
|
+
const dirName = parts[parts.length - 2];
|
|
154
|
+
if (!dirName)
|
|
155
|
+
return null;
|
|
156
|
+
const segments = dirName.split("-").filter(Boolean);
|
|
157
|
+
return segments.length ? segments[segments.length - 1] : dirName;
|
|
158
|
+
}
|
|
@@ -0,0 +1,165 @@
|
|
|
1
|
+
import { homedir } from "node:os";
|
|
2
|
+
import { join } from "node:path";
|
|
3
|
+
import { realpathSync, chmodSync, writeFileSync, unlinkSync, existsSync } from "node:fs";
|
|
4
|
+
import { execFileSync } from "node:child_process";
|
|
5
|
+
import { SCHEDULE_JOB_ID, SCHEDULE_LOG_PATH, SCHEDULE_MARKER, } from "./store.js";
|
|
6
|
+
export function resolveCliExecutable() {
|
|
7
|
+
const entry = process.argv[1];
|
|
8
|
+
if (!entry)
|
|
9
|
+
throw new Error("Could not resolve the devpulse executable path.");
|
|
10
|
+
try {
|
|
11
|
+
return realpathSync(entry);
|
|
12
|
+
}
|
|
13
|
+
catch {
|
|
14
|
+
return entry;
|
|
15
|
+
}
|
|
16
|
+
}
|
|
17
|
+
function launchdPlistPath() {
|
|
18
|
+
return join(homedir(), "Library", "LaunchAgents", `${SCHEDULE_JOB_ID}.plist`);
|
|
19
|
+
}
|
|
20
|
+
function cronLine(cliPath, spec) {
|
|
21
|
+
const log = `${SCHEDULE_LOG_PATH}`;
|
|
22
|
+
if (spec.mode === "interval") {
|
|
23
|
+
const hours = spec.intervalHours ?? 6;
|
|
24
|
+
return `0 */${hours} * * * ${cliPath} sync >> ${log} 2>&1 ${SCHEDULE_MARKER}`;
|
|
25
|
+
}
|
|
26
|
+
const [hour, minute] = (spec.dailyAt ?? "09:00").split(":");
|
|
27
|
+
return `${minute} ${hour} * * * ${cliPath} sync >> ${log} 2>&1 ${SCHEDULE_MARKER}`;
|
|
28
|
+
}
|
|
29
|
+
function launchdPlist(cliPath, spec) {
|
|
30
|
+
const log = SCHEDULE_LOG_PATH;
|
|
31
|
+
const scheduleKey = spec.mode === "interval"
|
|
32
|
+
? `<key>StartInterval</key>\n <integer>${(spec.intervalHours ?? 6) * 3600}</integer>`
|
|
33
|
+
: (() => {
|
|
34
|
+
const [hour, minute] = (spec.dailyAt ?? "09:00").split(":").map((v) => parseInt(v, 10));
|
|
35
|
+
return `<key>StartCalendarInterval</key>
|
|
36
|
+
<dict>
|
|
37
|
+
<key>Hour</key>
|
|
38
|
+
<integer>${hour}</integer>
|
|
39
|
+
<key>Minute</key>
|
|
40
|
+
<integer>${minute}</integer>
|
|
41
|
+
</dict>`;
|
|
42
|
+
})();
|
|
43
|
+
return `<?xml version="1.0" encoding="UTF-8"?>
|
|
44
|
+
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
|
45
|
+
<plist version="1.0">
|
|
46
|
+
<dict>
|
|
47
|
+
<key>Label</key>
|
|
48
|
+
<string>${SCHEDULE_JOB_ID}</string>
|
|
49
|
+
<key>ProgramArguments</key>
|
|
50
|
+
<array>
|
|
51
|
+
<string>${cliPath}</string>
|
|
52
|
+
<string>sync</string>
|
|
53
|
+
</array>
|
|
54
|
+
${scheduleKey}
|
|
55
|
+
<key>StandardOutPath</key>
|
|
56
|
+
<string>${log}</string>
|
|
57
|
+
<key>StandardErrorPath</key>
|
|
58
|
+
<string>${log}</string>
|
|
59
|
+
</dict>
|
|
60
|
+
</plist>
|
|
61
|
+
`;
|
|
62
|
+
}
|
|
63
|
+
function readCrontab() {
|
|
64
|
+
try {
|
|
65
|
+
return execFileSync("crontab", ["-l"], { encoding: "utf8" });
|
|
66
|
+
}
|
|
67
|
+
catch (err) {
|
|
68
|
+
const e = err;
|
|
69
|
+
const stderr = e.stderr?.toString() ?? "";
|
|
70
|
+
if (stderr.includes("no crontab") || e.status === 1)
|
|
71
|
+
return "";
|
|
72
|
+
throw err;
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
function writeCrontab(content) {
|
|
76
|
+
execFileSync("crontab", ["-"], { input: content, encoding: "utf8" });
|
|
77
|
+
}
|
|
78
|
+
function stripDevpulseCronLines(crontab) {
|
|
79
|
+
return crontab
|
|
80
|
+
.split("\n")
|
|
81
|
+
.filter((line) => !line.includes(SCHEDULE_MARKER))
|
|
82
|
+
.join("\n")
|
|
83
|
+
.replace(/\n+$/, "");
|
|
84
|
+
}
|
|
85
|
+
function platform() {
|
|
86
|
+
if (process.platform === "darwin")
|
|
87
|
+
return "launchd";
|
|
88
|
+
if (process.platform === "linux")
|
|
89
|
+
return "cron";
|
|
90
|
+
return null;
|
|
91
|
+
}
|
|
92
|
+
function ensureExecutable(cliPath) {
|
|
93
|
+
try {
|
|
94
|
+
chmodSync(cliPath, 0o755);
|
|
95
|
+
}
|
|
96
|
+
catch {
|
|
97
|
+
/* ignore if chmod fails */
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
function installLaunchd(cliPath, spec) {
|
|
101
|
+
const plistPath = launchdPlistPath();
|
|
102
|
+
writeFileSync(plistPath, launchdPlist(cliPath, spec), { mode: 0o644 });
|
|
103
|
+
try {
|
|
104
|
+
execFileSync("launchctl", ["unload", plistPath], { stdio: "ignore" });
|
|
105
|
+
}
|
|
106
|
+
catch {
|
|
107
|
+
/* not loaded yet */
|
|
108
|
+
}
|
|
109
|
+
execFileSync("launchctl", ["load", "-w", plistPath]);
|
|
110
|
+
}
|
|
111
|
+
function removeLaunchd() {
|
|
112
|
+
const plistPath = launchdPlistPath();
|
|
113
|
+
if (!existsSync(plistPath))
|
|
114
|
+
return;
|
|
115
|
+
try {
|
|
116
|
+
execFileSync("launchctl", ["unload", plistPath], { stdio: "ignore" });
|
|
117
|
+
}
|
|
118
|
+
catch {
|
|
119
|
+
/* ignore */
|
|
120
|
+
}
|
|
121
|
+
unlinkSync(plistPath);
|
|
122
|
+
}
|
|
123
|
+
function installCron(cliPath, spec) {
|
|
124
|
+
const base = stripDevpulseCronLines(readCrontab());
|
|
125
|
+
const line = cronLine(cliPath, spec);
|
|
126
|
+
const next = base ? `${base}\n${line}\n` : `${line}\n`;
|
|
127
|
+
writeCrontab(next);
|
|
128
|
+
}
|
|
129
|
+
function removeCron() {
|
|
130
|
+
const base = stripDevpulseCronLines(readCrontab());
|
|
131
|
+
writeCrontab(base ? `${base}\n` : "");
|
|
132
|
+
}
|
|
133
|
+
export function installSchedule(spec) {
|
|
134
|
+
const cliPath = resolveCliExecutable();
|
|
135
|
+
const p = platform();
|
|
136
|
+
if (!p) {
|
|
137
|
+
throw new Error(`Automatic scheduling is not supported on ${process.platform}. Run \`devpulse sync\` manually or use your OS scheduler.`);
|
|
138
|
+
}
|
|
139
|
+
ensureExecutable(cliPath);
|
|
140
|
+
if (p === "launchd")
|
|
141
|
+
installLaunchd(cliPath, spec);
|
|
142
|
+
else
|
|
143
|
+
installCron(cliPath, spec);
|
|
144
|
+
return {
|
|
145
|
+
mode: spec.mode,
|
|
146
|
+
intervalHours: spec.intervalHours,
|
|
147
|
+
dailyAt: spec.dailyAt,
|
|
148
|
+
cliPath,
|
|
149
|
+
platform: p,
|
|
150
|
+
installedAt: new Date().toISOString(),
|
|
151
|
+
};
|
|
152
|
+
}
|
|
153
|
+
export function removeSchedule(config) {
|
|
154
|
+
const p = config?.platform ?? platform();
|
|
155
|
+
if (p === "launchd")
|
|
156
|
+
removeLaunchd();
|
|
157
|
+
else if (p === "cron")
|
|
158
|
+
removeCron();
|
|
159
|
+
}
|
|
160
|
+
export function describeSchedule(config) {
|
|
161
|
+
if (config.mode === "interval") {
|
|
162
|
+
return `every ${config.intervalHours ?? "?"} hour(s)`;
|
|
163
|
+
}
|
|
164
|
+
return `daily at ${config.dailyAt ?? "09:00"}`;
|
|
165
|
+
}
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
import { join } from "node:path";
|
|
2
|
+
import { readFileSync, writeFileSync, existsSync, unlinkSync } from "node:fs";
|
|
3
|
+
import { paths } from "../config.js";
|
|
4
|
+
export const SCHEDULE_JOB_ID = "com.devpulse.sync";
|
|
5
|
+
export const SCHEDULE_MARKER = "# devpulse-schedule";
|
|
6
|
+
export const SCHEDULE_LOG_PATH = join(paths.CONFIG_DIR, "schedule.log");
|
|
7
|
+
export const SCHEDULE_CONFIG_PATH = join(paths.CONFIG_DIR, "schedule.json");
|
|
8
|
+
export function loadScheduleConfig() {
|
|
9
|
+
if (!existsSync(SCHEDULE_CONFIG_PATH))
|
|
10
|
+
return null;
|
|
11
|
+
try {
|
|
12
|
+
return JSON.parse(readFileSync(SCHEDULE_CONFIG_PATH, "utf8"));
|
|
13
|
+
}
|
|
14
|
+
catch {
|
|
15
|
+
return null;
|
|
16
|
+
}
|
|
17
|
+
}
|
|
18
|
+
export function saveScheduleConfig(config) {
|
|
19
|
+
writeFileSync(SCHEDULE_CONFIG_PATH, JSON.stringify(config, null, 2), { mode: 0o600 });
|
|
20
|
+
}
|
|
21
|
+
export function clearScheduleConfig() {
|
|
22
|
+
if (existsSync(SCHEDULE_CONFIG_PATH))
|
|
23
|
+
unlinkSync(SCHEDULE_CONFIG_PATH);
|
|
24
|
+
}
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
/** Strip transport/metadata wrappers so LLM sees the actual user intent. */
|
|
2
|
+
export function cleanUserText(raw) {
|
|
3
|
+
if (!raw?.trim())
|
|
4
|
+
return null;
|
|
5
|
+
let text = raw.trim();
|
|
6
|
+
text = text.replace(/Sender\s*\([^)]*\):\s*```[\s\S]*?```/gi, "");
|
|
7
|
+
text = text.replace(/```json\s*\{[\s\S]*?\}\s*```/g, "");
|
|
8
|
+
text = text.replace(/<environment_context>[\s\S]*?<\/environment_context>/gi, "");
|
|
9
|
+
text = text.replace(/#\s*Context from my IDE setup:[\s\S]*?## My request for Codex:\s*/gi, "");
|
|
10
|
+
text = text.replace(/<user_query>\s*/g, "").replace(/<\/user_query>/g, "");
|
|
11
|
+
text = text.replace(/<[^>]+>/g, " ");
|
|
12
|
+
text = text.replace(/\[(Mon|Tue|Wed|Thu|Fri|Sat|Sun)[^\]]*\]\s*/g, "");
|
|
13
|
+
text = text.replace(/\s+/g, " ").trim();
|
|
14
|
+
if (text.length < 6)
|
|
15
|
+
return null;
|
|
16
|
+
if (/^openclaw-tui/i.test(text) && text.length < 40)
|
|
17
|
+
return null;
|
|
18
|
+
return text;
|
|
19
|
+
}
|
|
20
|
+
/** Lightweight context for server-side LLM session summaries (not stored in DB). */
|
|
21
|
+
export function buildSummaryNotes(input) {
|
|
22
|
+
const messages = input.userMessages.map(cleanUserText).filter(Boolean);
|
|
23
|
+
const hasTitle = !!input.title?.trim();
|
|
24
|
+
if (messages.length === 0 && !hasTitle && !(input.files?.length))
|
|
25
|
+
return null;
|
|
26
|
+
const parts = [`Tool: ${input.tool}`];
|
|
27
|
+
if (input.projectName)
|
|
28
|
+
parts.push(`Project: ${input.projectName}`);
|
|
29
|
+
if (hasTitle)
|
|
30
|
+
parts.push(`Session title: ${input.title.trim()}`);
|
|
31
|
+
if (input.files?.length) {
|
|
32
|
+
parts.push(`Files edited: ${input.files.slice(0, 20).join(", ")}`);
|
|
33
|
+
}
|
|
34
|
+
if (messages.length) {
|
|
35
|
+
parts.push(`User requests:\n- ${messages.slice(0, 15).join("\n- ")}`);
|
|
36
|
+
}
|
|
37
|
+
const joined = parts.join("\n");
|
|
38
|
+
return joined.length > 8000 ? joined.slice(0, 7999).trimEnd() + "…" : joined;
|
|
39
|
+
}
|
package/dist/summary.js
ADDED
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Rule-based, session-level summary built locally during sync. No transcript
|
|
3
|
+
* content is uploaded — only this short derived string. An LLM-generated
|
|
4
|
+
* summary could replace this later behind the same signature.
|
|
5
|
+
*/
|
|
6
|
+
export function buildSessionSummary(input) {
|
|
7
|
+
const { firstUserText, explicitSummary, projectName, messageCount } = input;
|
|
8
|
+
// Prefer Claude Code's own "summary" entry when present.
|
|
9
|
+
if (explicitSummary && explicitSummary.trim()) {
|
|
10
|
+
return truncate(explicitSummary.trim(), 200);
|
|
11
|
+
}
|
|
12
|
+
if (firstUserText && firstUserText.trim()) {
|
|
13
|
+
const cleaned = firstUserText.replace(/\s+/g, " ").trim();
|
|
14
|
+
return truncate(cleaned, 200);
|
|
15
|
+
}
|
|
16
|
+
if (projectName) {
|
|
17
|
+
return `Worked in ${projectName} (${messageCount} messages).`;
|
|
18
|
+
}
|
|
19
|
+
return null;
|
|
20
|
+
}
|
|
21
|
+
function truncate(s, max) {
|
|
22
|
+
if (s.length <= max)
|
|
23
|
+
return s;
|
|
24
|
+
return s.slice(0, max - 1).trimEnd() + "…";
|
|
25
|
+
}
|
package/dist/types.js
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
package/dist/ui.js
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
import pc from "picocolors";
|
|
2
|
+
import { createInterface } from "node:readline";
|
|
3
|
+
export const ui = {
|
|
4
|
+
info: (msg) => console.log(msg),
|
|
5
|
+
success: (msg) => console.log(`${pc.green("✓")} ${msg}`),
|
|
6
|
+
warn: (msg) => console.log(`${pc.yellow("!")} ${msg}`),
|
|
7
|
+
error: (msg) => console.error(`${pc.red("✗")} ${msg}`),
|
|
8
|
+
dim: (msg) => console.log(pc.dim(msg)),
|
|
9
|
+
heading: (msg) => console.log(pc.bold(msg)),
|
|
10
|
+
pc,
|
|
11
|
+
};
|
|
12
|
+
/** Prompt for a single line of input (used for the login token). */
|
|
13
|
+
export function prompt(question) {
|
|
14
|
+
const rl = createInterface({ input: process.stdin, output: process.stdout });
|
|
15
|
+
return new Promise((resolve) => {
|
|
16
|
+
rl.question(question, (answer) => {
|
|
17
|
+
rl.close();
|
|
18
|
+
resolve(answer.trim());
|
|
19
|
+
});
|
|
20
|
+
});
|
|
21
|
+
}
|
package/package.json
ADDED
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "devpulse-ai",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "DevPulse AI CLI — sync local AI coding tool sessions to your team dashboard.",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"bin": {
|
|
7
|
+
"devpulse": "dist/index.js"
|
|
8
|
+
},
|
|
9
|
+
"repository": {
|
|
10
|
+
"type": "git",
|
|
11
|
+
"url": "https://github.com/szy1840/dev_pulse.git",
|
|
12
|
+
"directory": "cli"
|
|
13
|
+
},
|
|
14
|
+
"homepage": "https://7aj5nkyd.insforge.site",
|
|
15
|
+
"bugs": {
|
|
16
|
+
"url": "https://github.com/szy1840/dev_pulse/issues"
|
|
17
|
+
},
|
|
18
|
+
"files": [
|
|
19
|
+
"dist"
|
|
20
|
+
],
|
|
21
|
+
"engines": {
|
|
22
|
+
"node": ">=22"
|
|
23
|
+
},
|
|
24
|
+
"scripts": {
|
|
25
|
+
"build": "tsc -p tsconfig.json",
|
|
26
|
+
"dev": "tsx src/index.ts",
|
|
27
|
+
"prepublishOnly": "npm run build"
|
|
28
|
+
},
|
|
29
|
+
"keywords": [
|
|
30
|
+
"ai",
|
|
31
|
+
"claude-code",
|
|
32
|
+
"developer-tools",
|
|
33
|
+
"analytics",
|
|
34
|
+
"cli"
|
|
35
|
+
],
|
|
36
|
+
"license": "MIT",
|
|
37
|
+
"dependencies": {
|
|
38
|
+
"commander": "^13.1.0",
|
|
39
|
+
"picocolors": "^1.1.1"
|
|
40
|
+
},
|
|
41
|
+
"devDependencies": {
|
|
42
|
+
"@types/node": "^22.13.5",
|
|
43
|
+
"tsx": "^4.19.3",
|
|
44
|
+
"typescript": "^5.7.3"
|
|
45
|
+
}
|
|
46
|
+
}
|