configs

configs
git clone https://git.prime8.dev/configs.git
Log | Files | Refs | README

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 };