wakatime.js (19632B)
1 // src/index.ts 2 import * as path5 from "node:path"; 3 4 // src/logger.ts 5 import * as fs from "node:fs"; 6 import * as os from "node:os"; 7 import * as path from "node:path"; 8 var LogLevel = /* @__PURE__ */ ((LogLevel2) => { 9 LogLevel2[LogLevel2["DEBUG"] = 0] = "DEBUG"; 10 LogLevel2[LogLevel2["INFO"] = 1] = "INFO"; 11 LogLevel2[LogLevel2["WARN"] = 2] = "WARN"; 12 LogLevel2[LogLevel2["ERROR"] = 3] = "ERROR"; 13 return LogLevel2; 14 })(LogLevel || {}); 15 var LOG_FILE = path.join(os.homedir(), ".wakatime", "opencode.log"); 16 var Logger = class { 17 level = 1 /* INFO */; 18 setLevel(level) { 19 this.level = level; 20 } 21 debug(msg) { 22 this.log(0 /* DEBUG */, msg); 23 } 24 info(msg) { 25 this.log(1 /* INFO */, msg); 26 } 27 warn(msg) { 28 this.log(2 /* WARN */, msg); 29 } 30 error(msg) { 31 this.log(3 /* ERROR */, msg); 32 } 33 warnException(err) { 34 const message = err instanceof Error ? err.message : String(err); 35 this.warn(message); 36 } 37 errorException(err) { 38 const message = err instanceof Error ? err.message : String(err); 39 this.error(message); 40 } 41 log(level, msg) { 42 if (level < this.level) return; 43 const levelName = LogLevel[level]; 44 const timestamp2 = (/* @__PURE__ */ new Date()).toISOString(); 45 const line = `[${timestamp2}][${levelName}] ${msg} 46 `; 47 try { 48 const dir = path.dirname(LOG_FILE); 49 if (!fs.existsSync(dir)) { 50 fs.mkdirSync(dir, { recursive: true }); 51 } 52 fs.appendFileSync(LOG_FILE, line); 53 } catch { 54 } 55 } 56 }; 57 var logger = new Logger(); 58 59 // src/state.ts 60 import * as fs2 from "node:fs"; 61 import * as os2 from "node:os"; 62 import * as path2 from "node:path"; 63 var STATE_FILE = path2.join(os2.homedir(), ".wakatime", "opencode.json"); 64 function readState() { 65 try { 66 const content = fs2.readFileSync(STATE_FILE, "utf-8"); 67 return JSON.parse(content); 68 } catch { 69 return {}; 70 } 71 } 72 function writeState(state) { 73 try { 74 const dir = path2.dirname(STATE_FILE); 75 if (!fs2.existsSync(dir)) { 76 fs2.mkdirSync(dir, { recursive: true }); 77 } 78 fs2.writeFileSync(STATE_FILE, JSON.stringify(state, null, 2)); 79 } catch { 80 } 81 } 82 function timestamp() { 83 return Math.floor(Date.now() / 1e3); 84 } 85 function shouldSendHeartbeat(force = false) { 86 if (force) return true; 87 try { 88 const state = readState(); 89 const lastHeartbeat = state.lastHeartbeatAt ?? 0; 90 return timestamp() - lastHeartbeat >= 60; 91 } catch { 92 return true; 93 } 94 } 95 function updateLastHeartbeat() { 96 writeState({ lastHeartbeatAt: timestamp() }); 97 } 98 99 // src/wakatime.ts 100 import { execFile } from "node:child_process"; 101 import * as fs4 from "node:fs"; 102 import * as os4 from "node:os"; 103 import * as path4 from "node:path"; 104 import { fileURLToPath } from "node:url"; 105 106 // src/dependencies.ts 107 import { execSync } from "node:child_process"; 108 import * as fs3 from "node:fs"; 109 import { createWriteStream } from "node:fs"; 110 import * as https from "node:https"; 111 import * as os3 from "node:os"; 112 import * as path3 from "node:path"; 113 function whichSync(cmd) { 114 try { 115 const isWindows2 = os3.platform() === "win32"; 116 const result = execSync(isWindows2 ? `where ${cmd}` : `which ${cmd}`, { 117 encoding: "utf-8", 118 stdio: ["pipe", "pipe", "pipe"] 119 }); 120 const firstLine = result.trim().split("\n")[0]; 121 return firstLine || null; 122 } catch { 123 return null; 124 } 125 } 126 var GITHUB_RELEASES_URL = "https://api.github.com/repos/wakatime/wakatime-cli/releases/latest"; 127 var GITHUB_DOWNLOAD_URL = "https://github.com/wakatime/wakatime-cli/releases/latest/download"; 128 var UPDATE_CHECK_INTERVAL = 4 * 60 * 60 * 1e3; 129 var Dependencies = class { 130 resourcesLocation; 131 cliLocation; 132 stateFile; 133 constructor() { 134 this.resourcesLocation = path3.join(os3.homedir(), ".wakatime"); 135 this.stateFile = path3.join( 136 this.resourcesLocation, 137 "opencode-cli-state.json" 138 ); 139 } 140 isWindows() { 141 return os3.platform() === "win32"; 142 } 143 getOsName() { 144 const platform3 = os3.platform(); 145 if (platform3 === "win32") return "windows"; 146 return platform3; 147 } 148 getArchitecture() { 149 const arch2 = os3.arch(); 150 if (arch2 === "x64") return "amd64"; 151 if (arch2 === "ia32" || arch2.includes("32")) return "386"; 152 if (arch2 === "arm64") return "arm64"; 153 if (arch2 === "arm") return "arm"; 154 return arch2; 155 } 156 getCliBinaryName() { 157 const osname = this.getOsName(); 158 const arch2 = this.getArchitecture(); 159 const ext = this.isWindows() ? ".exe" : ""; 160 return `wakatime-cli-${osname}-${arch2}${ext}`; 161 } 162 getCliDownloadUrl() { 163 const osname = this.getOsName(); 164 const arch2 = this.getArchitecture(); 165 return `${GITHUB_DOWNLOAD_URL}/wakatime-cli-${osname}-${arch2}.zip`; 166 } 167 readState() { 168 try { 169 if (fs3.existsSync(this.stateFile)) { 170 return JSON.parse(fs3.readFileSync(this.stateFile, "utf-8")); 171 } 172 } catch { 173 } 174 return {}; 175 } 176 writeState(state) { 177 try { 178 fs3.mkdirSync(path3.dirname(this.stateFile), { recursive: true }); 179 fs3.writeFileSync(this.stateFile, JSON.stringify(state, null, 2)); 180 } catch { 181 } 182 } 183 getCliLocationGlobal() { 184 const binaryName = `wakatime-cli${this.isWindows() ? ".exe" : ""}`; 185 try { 186 const globalPath = whichSync(binaryName); 187 if (globalPath) { 188 logger.debug(`Found global wakatime-cli: ${globalPath}`); 189 return globalPath; 190 } 191 } catch { 192 } 193 return void 0; 194 } 195 getCliLocation() { 196 if (this.cliLocation) return this.cliLocation; 197 const globalCli = this.getCliLocationGlobal(); 198 if (globalCli) { 199 this.cliLocation = globalCli; 200 return this.cliLocation; 201 } 202 const binary = this.getCliBinaryName(); 203 this.cliLocation = path3.join(this.resourcesLocation, binary); 204 return this.cliLocation; 205 } 206 isCliInstalled() { 207 const location = this.getCliLocation(); 208 return fs3.existsSync(location); 209 } 210 shouldCheckForUpdates() { 211 if (this.getCliLocationGlobal()) { 212 return false; 213 } 214 const state = this.readState(); 215 if (!state.lastChecked) return true; 216 return Date.now() - state.lastChecked > UPDATE_CHECK_INTERVAL; 217 } 218 async checkAndInstallCli() { 219 if (this.getCliLocationGlobal()) { 220 logger.debug("Using global wakatime-cli, skipping installation check"); 221 return; 222 } 223 if (!this.isCliInstalled()) { 224 logger.info("wakatime-cli not found, downloading..."); 225 await this.installCli(); 226 return; 227 } 228 if (this.shouldCheckForUpdates()) { 229 logger.debug("Checking for wakatime-cli updates..."); 230 const latestVersion = await this.getLatestVersion(); 231 const state = this.readState(); 232 if (latestVersion && latestVersion !== state.version) { 233 logger.info(`Updating wakatime-cli to ${latestVersion}...`); 234 await this.installCli(); 235 this.writeState({ lastChecked: Date.now(), version: latestVersion }); 236 } else { 237 this.writeState({ ...state, lastChecked: Date.now() }); 238 } 239 } 240 } 241 async getLatestVersion() { 242 return new Promise((resolve) => { 243 const options = { 244 headers: { 245 "User-Agent": "opencode-wakatime" 246 } 247 }; 248 https.get(GITHUB_RELEASES_URL, options, (res) => { 249 let data = ""; 250 res.on("data", (chunk) => { 251 data += chunk; 252 }); 253 res.on("end", () => { 254 try { 255 const json = JSON.parse(data); 256 resolve(json.tag_name); 257 } catch { 258 resolve(void 0); 259 } 260 }); 261 }).on("error", () => { 262 resolve(void 0); 263 }); 264 }); 265 } 266 async installCli() { 267 const zipUrl = this.getCliDownloadUrl(); 268 const zipFile = path3.join( 269 this.resourcesLocation, 270 `wakatime-cli-${Date.now()}.zip` 271 ); 272 try { 273 fs3.mkdirSync(this.resourcesLocation, { recursive: true }); 274 logger.debug(`Downloading wakatime-cli from ${zipUrl}`); 275 await this.downloadFile(zipUrl, zipFile); 276 logger.debug(`Extracting wakatime-cli to ${this.resourcesLocation}`); 277 await this.extractZip(zipFile, this.resourcesLocation); 278 if (!this.isWindows()) { 279 const cliPath = this.getCliLocation(); 280 if (fs3.existsSync(cliPath)) { 281 fs3.chmodSync(cliPath, 493); 282 logger.debug(`Set executable permission on ${cliPath}`); 283 } 284 } 285 logger.info("wakatime-cli installed successfully"); 286 } catch (err) { 287 logger.errorException(err); 288 throw err; 289 } finally { 290 try { 291 if (fs3.existsSync(zipFile)) { 292 fs3.unlinkSync(zipFile); 293 } 294 } catch { 295 } 296 } 297 } 298 downloadFile(url, dest) { 299 return new Promise((resolve, reject) => { 300 const followRedirect = (url2, redirectCount = 0) => { 301 if (redirectCount > 5) { 302 reject(new Error("Too many redirects")); 303 return; 304 } 305 https.get(url2, (res) => { 306 if (res.statusCode === 301 || res.statusCode === 302) { 307 const location = res.headers.location; 308 if (location) { 309 followRedirect(location, redirectCount + 1); 310 return; 311 } 312 } 313 if (res.statusCode !== 200) { 314 reject(new Error(`HTTP ${res.statusCode}`)); 315 return; 316 } 317 const file = createWriteStream(dest); 318 res.pipe(file); 319 file.on("finish", () => { 320 file.close(); 321 resolve(); 322 }); 323 file.on("error", (err) => { 324 fs3.unlinkSync(dest); 325 reject(err); 326 }); 327 }).on("error", reject); 328 }; 329 followRedirect(url); 330 }); 331 } 332 async extractZip(zipFile, destDir) { 333 const { execSync: execSync2 } = await import("node:child_process"); 334 try { 335 if (this.isWindows()) { 336 execSync2( 337 `powershell -command "Expand-Archive -Force '${zipFile}' '${destDir}'"`, 338 { 339 windowsHide: true 340 } 341 ); 342 } else { 343 execSync2(`unzip -o "${zipFile}" -d "${destDir}"`, { 344 stdio: "ignore" 345 }); 346 } 347 } catch (_err) { 348 logger.warn("Native unzip failed, attempting manual extraction"); 349 await this.extractZipManual(zipFile, destDir); 350 } 351 } 352 async extractZipManual(zipFile, destDir) { 353 const data = fs3.readFileSync(zipFile); 354 let offset = 0; 355 while (offset < data.length - 4) { 356 if (data[offset] === 80 && data[offset + 1] === 75 && data[offset + 2] === 3 && data[offset + 3] === 4) { 357 const compressedSize = data.readUInt32LE(offset + 18); 358 const uncompressedSize = data.readUInt32LE(offset + 22); 359 const fileNameLength = data.readUInt16LE(offset + 26); 360 const extraFieldLength = data.readUInt16LE(offset + 28); 361 const fileName = data.slice(offset + 30, offset + 30 + fileNameLength).toString(); 362 const dataStart = offset + 30 + fileNameLength + extraFieldLength; 363 const compressionMethod = data.readUInt16LE(offset + 8); 364 if (fileName.includes("wakatime-cli")) { 365 const destPath = path3.join(destDir, path3.basename(fileName)); 366 if (compressionMethod === 0) { 367 const fileData = data.slice( 368 dataStart, 369 dataStart + uncompressedSize 370 ); 371 fs3.writeFileSync(destPath, fileData); 372 } else if (compressionMethod === 8) { 373 const { inflateRawSync } = await import("node:zlib"); 374 const compressedData = data.slice( 375 dataStart, 376 dataStart + compressedSize 377 ); 378 const decompressed = inflateRawSync(compressedData); 379 fs3.writeFileSync(destPath, decompressed); 380 } 381 logger.debug(`Extracted ${fileName} to ${destPath}`); 382 return; 383 } 384 offset = dataStart + compressedSize; 385 } else { 386 offset++; 387 } 388 } 389 throw new Error("Could not find wakatime-cli in zip file"); 390 } 391 }; 392 var dependencies = new Dependencies(); 393 394 // src/wakatime.ts 395 function getVersion() { 396 try { 397 const __dirname = path4.dirname(fileURLToPath(import.meta.url)); 398 const pkg = JSON.parse( 399 fs4.readFileSync(path4.join(__dirname, "..", "package.json"), "utf-8") 400 ); 401 return pkg.version; 402 } catch { 403 return "unknown"; 404 } 405 } 406 var VERSION = getVersion(); 407 function isWindows() { 408 return os4.platform() === "win32"; 409 } 410 function buildExecOptions() { 411 const options = { 412 windowsHide: true 413 }; 414 if (!isWindows() && !process.env.WAKATIME_HOME && !process.env.HOME) { 415 options.env = { ...process.env, WAKATIME_HOME: os4.homedir() }; 416 } 417 return options; 418 } 419 function formatArgs(args) { 420 return args.map((arg) => { 421 if (arg.includes(" ")) { 422 return `"${arg.replace(/"/g, '\\"')}"`; 423 } 424 return arg; 425 }).join(" "); 426 } 427 async function ensureCliInstalled() { 428 try { 429 await dependencies.checkAndInstallCli(); 430 return dependencies.isCliInstalled(); 431 } catch (err) { 432 logger.errorException(err); 433 return false; 434 } 435 } 436 function sendHeartbeat(params) { 437 const cliLocation = dependencies.getCliLocation(); 438 if (!dependencies.isCliInstalled()) { 439 logger.warn("wakatime-cli not installed, skipping heartbeat"); 440 return; 441 } 442 const args = [ 443 "--entity", 444 params.entity, 445 "--entity-type", 446 "file", 447 "--category", 448 params.category ?? "ai coding", 449 "--plugin", 450 `opencode/1.0.0 opencode-wakatime/${VERSION}` 451 ]; 452 if (params.projectFolder) { 453 args.push("--project-folder", params.projectFolder); 454 } 455 if (params.lineChanges !== void 0 && params.lineChanges !== 0) { 456 args.push("--ai-line-changes", params.lineChanges.toString()); 457 } 458 if (params.isWrite) { 459 args.push("--write"); 460 } 461 logger.debug(`Sending heartbeat: wakatime-cli ${formatArgs(args)}`); 462 const execOptions = buildExecOptions(); 463 execFile(cliLocation, args, execOptions, (error, stdout, stderr) => { 464 const output = (stdout?.toString().trim() ?? "") + (stderr?.toString().trim() ?? ""); 465 if (output) { 466 logger.debug(`wakatime-cli output: ${output}`); 467 } 468 if (error) { 469 logger.error(`wakatime-cli error: ${error.message}`); 470 } 471 }); 472 } 473 474 // src/index.ts 475 var fileChanges = /* @__PURE__ */ new Map(); 476 function extractFileChanges(tool, metadata, output, title) { 477 const changes = []; 478 if (!metadata) return changes; 479 switch (tool) { 480 case "edit": { 481 const filediff = metadata.filediff; 482 if (filediff?.file) { 483 changes.push({ 484 file: filediff.file, 485 info: { 486 additions: filediff.additions ?? 0, 487 deletions: filediff.deletions ?? 0, 488 isWrite: false 489 } 490 }); 491 } else { 492 const filePath = metadata.filePath; 493 if (filePath) { 494 changes.push({ 495 file: filePath, 496 info: { additions: 0, deletions: 0, isWrite: false } 497 }); 498 } 499 } 500 break; 501 } 502 case "write": { 503 const filepath = metadata.filepath; 504 const exists = metadata.exists; 505 if (filepath) { 506 changes.push({ 507 file: filepath, 508 info: { 509 additions: 0, 510 deletions: 0, 511 isWrite: !exists 512 // New file creation 513 } 514 }); 515 } 516 break; 517 } 518 case "patch": { 519 const diff = metadata.diff; 520 const lines = output.split("\n"); 521 const files = []; 522 for (const line of lines) { 523 if (line.startsWith(" ") && !line.startsWith(" ")) { 524 const file = line.trim(); 525 if (file && !file.includes(" ")) { 526 files.push(file); 527 } 528 } 529 } 530 const perFileDiff = files.length > 0 ? Math.round((diff ?? 0) / files.length) : 0; 531 for (const file of files) { 532 changes.push({ 533 file, 534 info: { 535 additions: perFileDiff > 0 ? perFileDiff : 0, 536 deletions: perFileDiff < 0 ? Math.abs(perFileDiff) : 0, 537 isWrite: false 538 } 539 }); 540 } 541 break; 542 } 543 case "multiedit": { 544 const results = metadata.results; 545 if (results) { 546 for (const result of results) { 547 if (result.filediff?.file) { 548 changes.push({ 549 file: result.filediff.file, 550 info: { 551 additions: result.filediff.additions ?? 0, 552 deletions: result.filediff.deletions ?? 0, 553 isWrite: false 554 } 555 }); 556 } 557 } 558 } 559 break; 560 } 561 case "read": { 562 if (title) { 563 changes.push({ 564 file: title, 565 info: { additions: 0, deletions: 0, isWrite: false } 566 }); 567 } 568 break; 569 } 570 case "glob": 571 case "grep": { 572 break; 573 } 574 case "bash": { 575 break; 576 } 577 } 578 return changes; 579 } 580 function processHeartbeat(projectFolder, force = false) { 581 if (!shouldSendHeartbeat(force) && !force) { 582 logger.debug("Skipping heartbeat (rate limited)"); 583 return; 584 } 585 if (fileChanges.size === 0) { 586 logger.debug("No file changes to report"); 587 return; 588 } 589 for (const [file, info] of fileChanges.entries()) { 590 const lineChanges = info.additions - info.deletions; 591 sendHeartbeat({ 592 entity: file, 593 projectFolder, 594 lineChanges, 595 category: "ai coding", 596 isWrite: info.isWrite 597 }); 598 logger.debug( 599 `Sent heartbeat for ${file}: +${info.additions}/-${info.deletions} lines` 600 ); 601 } 602 fileChanges.clear(); 603 updateLastHeartbeat(); 604 } 605 function trackFileChange(file, info) { 606 const existing = fileChanges.get(file) ?? { 607 additions: 0, 608 deletions: 0, 609 lastModified: Date.now(), 610 isWrite: false 611 }; 612 fileChanges.set(file, { 613 additions: existing.additions + (info.additions ?? 0), 614 deletions: existing.deletions + (info.deletions ?? 0), 615 lastModified: Date.now(), 616 isWrite: existing.isWrite || (info.isWrite ?? false) 617 }); 618 } 619 var plugin = async (ctx) => { 620 const { project, worktree } = ctx; 621 const projectName = path5.basename(worktree || project.worktree); 622 const cliInstalled = await ensureCliInstalled(); 623 if (!cliInstalled) { 624 logger.warn( 625 "WakaTime CLI could not be installed. Please install it manually: https://wakatime.com/terminal" 626 ); 627 } else { 628 logger.info( 629 `OpenCode WakaTime plugin initialized for project: ${projectName}` 630 ); 631 } 632 const projectFolder = worktree || process.cwd(); 633 const hooks = { 634 // Track tool executions that modify files 635 "tool.execute.after": async (input, output) => { 636 const { tool } = input; 637 const { metadata, title } = output; 638 logger.debug(`Tool executed: ${tool} - ${title}`); 639 const changes = extractFileChanges( 640 tool, 641 metadata, 642 output.output, 643 title 644 ); 645 for (const change of changes) { 646 trackFileChange(change.file, change.info); 647 logger.debug( 648 `Tracked: ${change.file} (+${change.info.additions ?? 0}/-${change.info.deletions ?? 0})` 649 ); 650 } 651 if (changes.length > 0) { 652 processHeartbeat(projectFolder); 653 } 654 }, 655 // Track chat activity 656 "chat.message": async (_input, _output) => { 657 logger.debug("Chat message received"); 658 if (fileChanges.size > 0) { 659 processHeartbeat(projectFolder); 660 } 661 }, 662 // Listen to all events for session lifecycle 663 event: async ({ event }) => { 664 if (event.type === "session.deleted" || event.type === "session.idle") { 665 logger.debug(`Session event: ${event.type} - sending final heartbeat`); 666 processHeartbeat(projectFolder, true); 667 } 668 } 669 }; 670 return hooks; 671 }; 672 var index_default = plugin; 673 export { 674 index_default as default, 675 extractFileChanges, 676 plugin 677 };