@chronova/pi-plugin 1.0.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/LICENSE +21 -0
- package/README.md +88 -0
- package/dist/heartbeat.js +79 -0
- package/dist/index.js +101 -0
- package/dist/logger.js +44 -0
- package/dist/state.js +45 -0
- package/dist/tracker.js +154 -0
- package/package.json +61 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 NX Solutions UG (haftungsbeschränkt)
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
# chronova-pi-plugin
|
|
2
|
+
|
|
3
|
+
Chronova heartbeat tracking extension for [oh-my-pi](https://omp.sh).
|
|
4
|
+
|
|
5
|
+
Automatically sends coding activity heartbeats to your [Chronova](https://chronova.dev) dashboard via `chronova-cli` — a drop-in replacement for `wakatime-cli`.
|
|
6
|
+
|
|
7
|
+
## Features
|
|
8
|
+
|
|
9
|
+
- **Automatic tracking** — Monitors `read`, `edit`, `write`, and `ast_edit` tool results from oh-my-pi
|
|
10
|
+
- **AI line changes** — Reports net AI-written lines (`--ai-line-changes`) for edit and write operations
|
|
11
|
+
- **Rate limiting** — 1 heartbeat per minute per project, persisted to disk across restarts
|
|
12
|
+
- **Force flush on exit** — All pending changes are flushed when the session shuts down
|
|
13
|
+
- **Fire-and-forget** — Heartbeat invocations never block the agent loop
|
|
14
|
+
|
|
15
|
+
## Prerequisites
|
|
16
|
+
|
|
17
|
+
- [oh-my-pi](https://omp.sh) (pi-coding-agent v15+)
|
|
18
|
+
- [chronova-cli](https://github.com/nx-solutions-ug/chronova-cli) installed at `~/.local/bin/chronova-cli`
|
|
19
|
+
- A Chronova account with API key configured in `~/.chronova.cfg`
|
|
20
|
+
|
|
21
|
+
## Installation
|
|
22
|
+
|
|
23
|
+
### Option 1: Clone and build
|
|
24
|
+
|
|
25
|
+
```bash
|
|
26
|
+
git clone https://github.com/nx-solutions-ug/chronova-pi-plugin.git ~/.projects/chronova-pi-plugin
|
|
27
|
+
cd ~/.projects/chronova-pi-plugin
|
|
28
|
+
npm install
|
|
29
|
+
npm run build
|
|
30
|
+
ln -s ~/.projects/chronova-pi-plugin ~/.omp/agent/extensions/chronova-pi-plugin
|
|
31
|
+
```
|
|
32
|
+
|
|
33
|
+
### Option 2: Direct symlink (requires Node.js for build)
|
|
34
|
+
|
|
35
|
+
```bash
|
|
36
|
+
git clone https://github.com/nx-solutions-ug/chronova-pi-plugin.git /tmp/chronova-pi-plugin
|
|
37
|
+
cd /tmp/chronova-pi-plugin
|
|
38
|
+
npm install && npm run build
|
|
39
|
+
mkdir -p ~/.omp/agent/extensions/chronova-pi-plugin
|
|
40
|
+
cp dist/*.js package.json ~/.omp/agent/extensions/chronova-pi-plugin/
|
|
41
|
+
```
|
|
42
|
+
|
|
43
|
+
## Configuration
|
|
44
|
+
|
|
45
|
+
The plugin reads the API key from `~/.chronova.cfg` automatically (same as `chronova-cli`). No separate configuration is needed.
|
|
46
|
+
|
|
47
|
+
### Debug Logging
|
|
48
|
+
|
|
49
|
+
Set `CHRONOVA_PI_DEBUG=1` or add `debug = true` to `~/.chronova.cfg` to enable verbose logging to `~/.chronova-pi-plugin/plugin.log`.
|
|
50
|
+
|
|
51
|
+
## How It Works
|
|
52
|
+
|
|
53
|
+
1. On `session_start`, the plugin records the project directory
|
|
54
|
+
2. On `tool_result` events, it extracts file paths and line changes:
|
|
55
|
+
- **read** → tracked as view (no line changes)
|
|
56
|
+
- **edit** → diff parsed for additions/deletions
|
|
57
|
+
- **write** → tracked as write operation
|
|
58
|
+
- **ast_edit** → tracked from `fileReplacements` count
|
|
59
|
+
3. When the rate limit allows (1/min/project), pending changes are flushed to `chronova-cli`
|
|
60
|
+
4. On `session_shutdown`, all remaining changes are force-flushed
|
|
61
|
+
|
|
62
|
+
### chronova-cli Arguments
|
|
63
|
+
|
|
64
|
+
```bash
|
|
65
|
+
chronova-cli \
|
|
66
|
+
--entity <absolute-file-path> \
|
|
67
|
+
--entity-type file \
|
|
68
|
+
--project-folder <project-directory> \
|
|
69
|
+
--plugin "oh-my-pi/1.0.0 chronova-pi-plugin/1.0.0" \
|
|
70
|
+
--category "ai coding" \
|
|
71
|
+
--write \ # for write operations
|
|
72
|
+
--ai-line-changes <net-lines> # additions - deletions
|
|
73
|
+
```
|
|
74
|
+
|
|
75
|
+
## Project Structure
|
|
76
|
+
|
|
77
|
+
```
|
|
78
|
+
src/
|
|
79
|
+
index.ts Extension factory: registers event handlers on ExtensionAPI
|
|
80
|
+
heartbeat.ts chronova-cli invocation (fire-and-forget via execFile)
|
|
81
|
+
tracker.ts File-change extraction from tool_result events
|
|
82
|
+
state.ts Per-project rate-limit state (persisted to disk)
|
|
83
|
+
logger.ts Debug logger writing to ~/.chronova-pi-plugin/plugin.log
|
|
84
|
+
```
|
|
85
|
+
|
|
86
|
+
## License
|
|
87
|
+
|
|
88
|
+
MIT © NX Solutions UG
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
import { execFile } from "node:child_process";
|
|
2
|
+
import * as path from "node:path";
|
|
3
|
+
import { logger } from "./logger.js";
|
|
4
|
+
import { shouldSendHeartbeat, updateLastHeartbeat } from "./state.js";
|
|
5
|
+
const PLUGIN_VERSION = "1.0.0";
|
|
6
|
+
const CLI_PATH = path.join(process.env.HOME ?? "/home/dev", ".local/bin/chronova-cli");
|
|
7
|
+
/**
|
|
8
|
+
* Send a heartbeat via chronova-cli. Fire-and-forget — never blocks
|
|
9
|
+
* the agent loop. Resolves after the process is spawned (not after it exits).
|
|
10
|
+
*/
|
|
11
|
+
export function sendHeartbeat(payload) {
|
|
12
|
+
if (!shouldSendHeartbeat(payload.projectFolder)) {
|
|
13
|
+
logger.debug("Heartbeat rate-limited, skipping", { projectFolder: payload.projectFolder });
|
|
14
|
+
return;
|
|
15
|
+
}
|
|
16
|
+
const args = [
|
|
17
|
+
"--entity", payload.entity,
|
|
18
|
+
"--entity-type", "file",
|
|
19
|
+
"--project-folder", payload.projectFolder,
|
|
20
|
+
"--plugin", `oh-my-pi/1.0.0 chronova-pi-plugin/${PLUGIN_VERSION}`,
|
|
21
|
+
"--category", "ai coding",
|
|
22
|
+
];
|
|
23
|
+
if (payload.isWrite) {
|
|
24
|
+
args.push("--write");
|
|
25
|
+
}
|
|
26
|
+
if (payload.aiLineChanges !== 0) {
|
|
27
|
+
args.push("--ai-line-changes", String(payload.aiLineChanges));
|
|
28
|
+
}
|
|
29
|
+
logger.debug("Spawning chronova-cli", { args });
|
|
30
|
+
const child = execFile(CLI_PATH, args, (err, stdout, stderr) => {
|
|
31
|
+
if (err) {
|
|
32
|
+
logger.error("chronova-cli error", { error: String(err) });
|
|
33
|
+
return;
|
|
34
|
+
}
|
|
35
|
+
if (stderr) {
|
|
36
|
+
logger.warn("chronova-cli stderr", { stderr: stderr.trim() });
|
|
37
|
+
}
|
|
38
|
+
if (stdout) {
|
|
39
|
+
logger.debug("chronova-cli stdout", { stdout: stdout.trim() });
|
|
40
|
+
}
|
|
41
|
+
});
|
|
42
|
+
child.unref();
|
|
43
|
+
updateLastHeartbeat(payload.projectFolder);
|
|
44
|
+
}
|
|
45
|
+
/**
|
|
46
|
+
* Force-send a heartbeat, bypassing rate limits.
|
|
47
|
+
* Used for session shutdown flush.
|
|
48
|
+
*/
|
|
49
|
+
export function sendHeartbeatForce(payload) {
|
|
50
|
+
if (!shouldSendHeartbeat(payload.projectFolder, true)) {
|
|
51
|
+
// Always true with force, but keep the guard for type safety
|
|
52
|
+
return;
|
|
53
|
+
}
|
|
54
|
+
const args = [
|
|
55
|
+
"--entity", payload.entity,
|
|
56
|
+
"--entity-type", "file",
|
|
57
|
+
"--project-folder", payload.projectFolder,
|
|
58
|
+
"--plugin", `oh-my-pi/1.0.0 chronova-pi-plugin/${PLUGIN_VERSION}`,
|
|
59
|
+
"--category", "ai coding",
|
|
60
|
+
];
|
|
61
|
+
if (payload.isWrite) {
|
|
62
|
+
args.push("--write");
|
|
63
|
+
}
|
|
64
|
+
if (payload.aiLineChanges !== 0) {
|
|
65
|
+
args.push("--ai-line-changes", String(payload.aiLineChanges));
|
|
66
|
+
}
|
|
67
|
+
logger.debug("Spawning chronova-cli (forced)", { args });
|
|
68
|
+
const child = execFile(CLI_PATH, args, (err, stdout, stderr) => {
|
|
69
|
+
if (err) {
|
|
70
|
+
logger.error("chronova-cli error (forced)", { error: String(err) });
|
|
71
|
+
return;
|
|
72
|
+
}
|
|
73
|
+
if (stderr) {
|
|
74
|
+
logger.warn("chronova-cli stderr (forced)", { stderr: stderr.trim() });
|
|
75
|
+
}
|
|
76
|
+
});
|
|
77
|
+
child.unref();
|
|
78
|
+
updateLastHeartbeat(payload.projectFolder);
|
|
79
|
+
}
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
import { logger } from "./logger.js";
|
|
2
|
+
import { sendHeartbeat, sendHeartbeatForce } from "./heartbeat.js";
|
|
3
|
+
import { trackRead, trackWrite, trackEdit, flushPending, pendingCount, resolvePath } from "./tracker.js";
|
|
4
|
+
import { shouldSendHeartbeat } from "./state.js";
|
|
5
|
+
export default function chronovaPiPlugin(pi) {
|
|
6
|
+
pi.setLabel("Chronova Heartbeat");
|
|
7
|
+
let projectFolder = "";
|
|
8
|
+
// --- session_start: set up project folder ---
|
|
9
|
+
pi.on("session_start", async (_event, ctx) => {
|
|
10
|
+
projectFolder = ctx.cwd;
|
|
11
|
+
logger.info("Chronova tracking active", { projectFolder });
|
|
12
|
+
});
|
|
13
|
+
// --- tool_result: extract file changes and flush heartbeats ---
|
|
14
|
+
pi.on("tool_result", async (event, _ctx) => {
|
|
15
|
+
if (!projectFolder)
|
|
16
|
+
return;
|
|
17
|
+
const input = event.input;
|
|
18
|
+
switch (event.toolName) {
|
|
19
|
+
case "read": {
|
|
20
|
+
const filePath = input.path;
|
|
21
|
+
if (filePath) {
|
|
22
|
+
trackRead(resolvePath(projectFolder, filePath));
|
|
23
|
+
tryFlush();
|
|
24
|
+
}
|
|
25
|
+
break;
|
|
26
|
+
}
|
|
27
|
+
case "edit": {
|
|
28
|
+
const details = event.details;
|
|
29
|
+
if (details) {
|
|
30
|
+
const resolvedDetails = {
|
|
31
|
+
...details,
|
|
32
|
+
path: details.path ? resolvePath(projectFolder, details.path) : undefined,
|
|
33
|
+
perFileResults: details.perFileResults?.map(r => ({
|
|
34
|
+
...r,
|
|
35
|
+
path: resolvePath(projectFolder, r.path),
|
|
36
|
+
})),
|
|
37
|
+
};
|
|
38
|
+
trackEdit(resolvedDetails);
|
|
39
|
+
tryFlush();
|
|
40
|
+
}
|
|
41
|
+
break;
|
|
42
|
+
}
|
|
43
|
+
case "write": {
|
|
44
|
+
const filePath = input.path;
|
|
45
|
+
if (filePath) {
|
|
46
|
+
trackWrite(resolvePath(projectFolder, filePath));
|
|
47
|
+
tryFlush();
|
|
48
|
+
}
|
|
49
|
+
break;
|
|
50
|
+
}
|
|
51
|
+
case "ast_edit": {
|
|
52
|
+
const details = event.details;
|
|
53
|
+
if (details) {
|
|
54
|
+
trackEdit({
|
|
55
|
+
files: details.files?.map(f => resolvePath(projectFolder, f)),
|
|
56
|
+
fileReplacements: details.fileReplacements?.map(r => ({
|
|
57
|
+
...r,
|
|
58
|
+
path: resolvePath(projectFolder, r.path),
|
|
59
|
+
})),
|
|
60
|
+
});
|
|
61
|
+
tryFlush();
|
|
62
|
+
}
|
|
63
|
+
break;
|
|
64
|
+
}
|
|
65
|
+
default:
|
|
66
|
+
break;
|
|
67
|
+
}
|
|
68
|
+
});
|
|
69
|
+
// --- session_shutdown: force-flush pending heartbeats ---
|
|
70
|
+
pi.on("session_shutdown", async () => {
|
|
71
|
+
if (!projectFolder)
|
|
72
|
+
return;
|
|
73
|
+
const count = pendingCount();
|
|
74
|
+
if (count > 0) {
|
|
75
|
+
logger.info("Flushing pending heartbeats on shutdown", { count });
|
|
76
|
+
const payloads = flushPending(projectFolder);
|
|
77
|
+
for (const payload of payloads) {
|
|
78
|
+
sendHeartbeatForce(payload);
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
});
|
|
82
|
+
// --- internal ---
|
|
83
|
+
/**
|
|
84
|
+
* Only flush pending changes when the rate limit allows.
|
|
85
|
+
* If rate-limited, changes stay in the pending map for the next opportunity.
|
|
86
|
+
*/
|
|
87
|
+
function tryFlush() {
|
|
88
|
+
if (!projectFolder || pendingCount() === 0)
|
|
89
|
+
return;
|
|
90
|
+
if (!shouldSendHeartbeat(projectFolder)) {
|
|
91
|
+
logger.debug("Rate-limited, keeping pending changes", {
|
|
92
|
+
pendingCount: pendingCount(),
|
|
93
|
+
});
|
|
94
|
+
return;
|
|
95
|
+
}
|
|
96
|
+
const payloads = flushPending(projectFolder);
|
|
97
|
+
for (const payload of payloads) {
|
|
98
|
+
sendHeartbeat(payload);
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
}
|
package/dist/logger.js
ADDED
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
import * as fs from "node:fs";
|
|
2
|
+
import * as path from "node:path";
|
|
3
|
+
import * as os from "node:os";
|
|
4
|
+
const LOG_DIR = path.join(os.homedir(), ".chronova-pi-plugin");
|
|
5
|
+
const LOG_FILE = path.join(LOG_DIR, "plugin.log");
|
|
6
|
+
let debugEnabled;
|
|
7
|
+
function isDebugEnabled() {
|
|
8
|
+
if (debugEnabled !== undefined)
|
|
9
|
+
return debugEnabled;
|
|
10
|
+
if (process.env.CHRONOVA_PI_DEBUG === "1") {
|
|
11
|
+
debugEnabled = true;
|
|
12
|
+
return true;
|
|
13
|
+
}
|
|
14
|
+
try {
|
|
15
|
+
const cfgPath = path.join(os.homedir(), ".chronova.cfg");
|
|
16
|
+
const content = fs.readFileSync(cfgPath, "utf-8");
|
|
17
|
+
debugEnabled = /debug\s*=\s*true/i.test(content);
|
|
18
|
+
}
|
|
19
|
+
catch {
|
|
20
|
+
debugEnabled = false;
|
|
21
|
+
}
|
|
22
|
+
return debugEnabled;
|
|
23
|
+
}
|
|
24
|
+
function write(level, msg, data) {
|
|
25
|
+
if (level === "DEBUG" && !isDebugEnabled())
|
|
26
|
+
return;
|
|
27
|
+
try {
|
|
28
|
+
fs.mkdirSync(LOG_DIR, { recursive: true });
|
|
29
|
+
const ts = new Date().toISOString();
|
|
30
|
+
const line = data !== undefined
|
|
31
|
+
? `[${ts}] [${level}] ${msg} ${JSON.stringify(data)}\n`
|
|
32
|
+
: `[${ts}] [${level}] ${msg}\n`;
|
|
33
|
+
fs.appendFileSync(LOG_FILE, line);
|
|
34
|
+
}
|
|
35
|
+
catch {
|
|
36
|
+
// Swallow log write failures — never crash the extension
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
export const logger = {
|
|
40
|
+
debug(msg, data) { write("DEBUG", msg, data); },
|
|
41
|
+
info(msg, data) { write("INFO", msg, data); },
|
|
42
|
+
warn(msg, data) { write("WARN", msg, data); },
|
|
43
|
+
error(msg, data) { write("ERROR", msg, data); },
|
|
44
|
+
};
|
package/dist/state.js
ADDED
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
import * as fs from "node:fs";
|
|
2
|
+
import * as path from "node:path";
|
|
3
|
+
import * as os from "node:os";
|
|
4
|
+
import * as crypto from "node:crypto";
|
|
5
|
+
import { logger } from "./logger.js";
|
|
6
|
+
const STATE_DIR = path.join(os.homedir(), ".chronova-pi-plugin", "state");
|
|
7
|
+
const RATE_LIMIT_SECONDS = 60;
|
|
8
|
+
function projectStateFile(projectFolder) {
|
|
9
|
+
const hash = crypto.createHash("sha256").update(projectFolder).digest("hex").slice(0, 16);
|
|
10
|
+
return path.join(STATE_DIR, `${hash}.json`);
|
|
11
|
+
}
|
|
12
|
+
function readState(filePath) {
|
|
13
|
+
try {
|
|
14
|
+
const raw = fs.readFileSync(filePath, "utf-8");
|
|
15
|
+
return JSON.parse(raw);
|
|
16
|
+
}
|
|
17
|
+
catch {
|
|
18
|
+
return null;
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
function writeState(filePath, state) {
|
|
22
|
+
try {
|
|
23
|
+
fs.mkdirSync(STATE_DIR, { recursive: true });
|
|
24
|
+
fs.writeFileSync(filePath, JSON.stringify(state));
|
|
25
|
+
}
|
|
26
|
+
catch (err) {
|
|
27
|
+
logger.error("Failed to write state file", { filePath, error: String(err) });
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
export function shouldSendHeartbeat(projectFolder, force = false) {
|
|
31
|
+
if (force)
|
|
32
|
+
return true;
|
|
33
|
+
const stateFile = projectStateFile(projectFolder);
|
|
34
|
+
const state = readState(stateFile);
|
|
35
|
+
if (!state)
|
|
36
|
+
return true;
|
|
37
|
+
const elapsed = Math.floor(Date.now() / 1000) - state.lastHeartbeatAt;
|
|
38
|
+
return elapsed >= RATE_LIMIT_SECONDS;
|
|
39
|
+
}
|
|
40
|
+
export function updateLastHeartbeat(projectFolder) {
|
|
41
|
+
const stateFile = projectStateFile(projectFolder);
|
|
42
|
+
const now = Math.floor(Date.now() / 1000);
|
|
43
|
+
writeState(stateFile, { lastHeartbeatAt: now });
|
|
44
|
+
logger.debug("Updated heartbeat state", { projectFolder, lastHeartbeatAt: now });
|
|
45
|
+
}
|
package/dist/tracker.js
ADDED
|
@@ -0,0 +1,154 @@
|
|
|
1
|
+
import * as path from "node:path";
|
|
2
|
+
import { logger } from "./logger.js";
|
|
3
|
+
const pending = new Map();
|
|
4
|
+
/**
|
|
5
|
+
* Record a file view (read-only). Zero line changes.
|
|
6
|
+
*/
|
|
7
|
+
export function trackRead(filePath) {
|
|
8
|
+
const absPath = resolveAbs(filePath);
|
|
9
|
+
if (!absPath)
|
|
10
|
+
return;
|
|
11
|
+
const existing = pending.get(absPath);
|
|
12
|
+
if (!existing) {
|
|
13
|
+
pending.set(absPath, { additions: 0, deletions: 0, isWrite: false });
|
|
14
|
+
logger.debug("Tracked read", { path: absPath });
|
|
15
|
+
}
|
|
16
|
+
}
|
|
17
|
+
/**
|
|
18
|
+
* Record a file write. Marked as isWrite=true.
|
|
19
|
+
*/
|
|
20
|
+
export function trackWrite(filePath) {
|
|
21
|
+
const absPath = resolveAbs(filePath);
|
|
22
|
+
if (!absPath)
|
|
23
|
+
return;
|
|
24
|
+
const existing = pending.get(absPath);
|
|
25
|
+
if (existing) {
|
|
26
|
+
existing.isWrite = true;
|
|
27
|
+
}
|
|
28
|
+
else {
|
|
29
|
+
pending.set(absPath, { additions: 0, deletions: 0, isWrite: true });
|
|
30
|
+
}
|
|
31
|
+
logger.debug("Tracked write", { path: absPath });
|
|
32
|
+
}
|
|
33
|
+
/**
|
|
34
|
+
* Record an edit with line changes.
|
|
35
|
+
* If perFileResults is available, extract per-file diffs.
|
|
36
|
+
* Otherwise use the top-level diff for a single-file edit.
|
|
37
|
+
*/
|
|
38
|
+
export function trackEdit(details) {
|
|
39
|
+
if (details.perFileResults && details.perFileResults.length > 0) {
|
|
40
|
+
for (const result of details.perFileResults) {
|
|
41
|
+
if (result.isError)
|
|
42
|
+
continue;
|
|
43
|
+
const absPath = resolveAbs(result.path);
|
|
44
|
+
if (!absPath)
|
|
45
|
+
continue;
|
|
46
|
+
const lineChanges = result.diff ? countLineChanges(result.diff) : { additions: 0, deletions: 0 };
|
|
47
|
+
mergeChange(absPath, { ...lineChanges, isWrite: true });
|
|
48
|
+
logger.debug("Tracked edit (perFile)", { path: absPath, ...lineChanges });
|
|
49
|
+
}
|
|
50
|
+
return;
|
|
51
|
+
}
|
|
52
|
+
// ast_edit reports touched files + per-file replacement counts
|
|
53
|
+
if (details.files && details.files.length > 0) {
|
|
54
|
+
for (const filePath of details.files) {
|
|
55
|
+
const absPath = resolveAbs(filePath);
|
|
56
|
+
if (!absPath)
|
|
57
|
+
continue;
|
|
58
|
+
const count = details.fileReplacements?.find(r => r.path === filePath)?.count ?? 1;
|
|
59
|
+
mergeChange(absPath, { additions: count, deletions: 0, isWrite: true });
|
|
60
|
+
logger.debug("Tracked ast_edit", { path: absPath, count });
|
|
61
|
+
}
|
|
62
|
+
return;
|
|
63
|
+
}
|
|
64
|
+
// Single-file edit with top-level diff
|
|
65
|
+
const filePath = details.path;
|
|
66
|
+
if (!filePath) {
|
|
67
|
+
logger.debug("Skipped edit: no path in details");
|
|
68
|
+
return;
|
|
69
|
+
}
|
|
70
|
+
const absPath = resolveAbs(filePath);
|
|
71
|
+
if (!absPath)
|
|
72
|
+
return;
|
|
73
|
+
const lineChanges = details.diff ? countLineChanges(details.diff) : { additions: 1, deletions: 0 };
|
|
74
|
+
mergeChange(absPath, { ...lineChanges, isWrite: true });
|
|
75
|
+
logger.debug("Tracked edit (single)", { path: absPath, ...lineChanges });
|
|
76
|
+
}
|
|
77
|
+
/**
|
|
78
|
+
* Flush all pending file changes into heartbeat payloads.
|
|
79
|
+
* Clears the pending map after extraction.
|
|
80
|
+
*/
|
|
81
|
+
export function flushPending(projectFolder) {
|
|
82
|
+
if (pending.size === 0)
|
|
83
|
+
return [];
|
|
84
|
+
const payloads = [];
|
|
85
|
+
for (const [entity, change] of pending) {
|
|
86
|
+
payloads.push({
|
|
87
|
+
entity,
|
|
88
|
+
projectFolder,
|
|
89
|
+
isWrite: change.isWrite,
|
|
90
|
+
aiLineChanges: change.additions - change.deletions,
|
|
91
|
+
});
|
|
92
|
+
}
|
|
93
|
+
pending.clear();
|
|
94
|
+
logger.debug("Flushed pending heartbeats", { count: payloads.length });
|
|
95
|
+
return payloads;
|
|
96
|
+
}
|
|
97
|
+
/**
|
|
98
|
+
* Get the number of pending file changes (for debugging/force-flush).
|
|
99
|
+
*/
|
|
100
|
+
export function pendingCount() {
|
|
101
|
+
return pending.size;
|
|
102
|
+
}
|
|
103
|
+
// --- internal helpers ---
|
|
104
|
+
function resolveAbs(filePath) {
|
|
105
|
+
if (!filePath)
|
|
106
|
+
return null;
|
|
107
|
+
// Already absolute
|
|
108
|
+
if (path.isAbsolute(filePath))
|
|
109
|
+
return filePath;
|
|
110
|
+
// Resolve relative to cwd — but we don't know cwd here.
|
|
111
|
+
// The extension context provides cwd; we'll resolve in the entry point.
|
|
112
|
+
// For now, just return as-is; the caller should pass absolute paths.
|
|
113
|
+
return filePath;
|
|
114
|
+
}
|
|
115
|
+
/**
|
|
116
|
+
* Resolve a possibly-relative path against a base directory.
|
|
117
|
+
*/
|
|
118
|
+
export function resolvePath(base, filePath) {
|
|
119
|
+
if (path.isAbsolute(filePath))
|
|
120
|
+
return filePath;
|
|
121
|
+
return path.resolve(base, filePath);
|
|
122
|
+
}
|
|
123
|
+
function mergeChange(absPath, change) {
|
|
124
|
+
const existing = pending.get(absPath);
|
|
125
|
+
if (existing) {
|
|
126
|
+
existing.additions += change.additions;
|
|
127
|
+
existing.deletions += change.deletions;
|
|
128
|
+
if (change.isWrite)
|
|
129
|
+
existing.isWrite = true;
|
|
130
|
+
}
|
|
131
|
+
else {
|
|
132
|
+
pending.set(absPath, { ...change });
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
/**
|
|
136
|
+
* Count additions and deletions from a unified diff string.
|
|
137
|
+
* Lines starting with '+' (after the diff header) are additions.
|
|
138
|
+
* Lines starting with '-' are deletions.
|
|
139
|
+
* Ignore the '+++' and '---' header lines.
|
|
140
|
+
*/
|
|
141
|
+
function countLineChanges(diff) {
|
|
142
|
+
let additions = 0;
|
|
143
|
+
let deletions = 0;
|
|
144
|
+
const lines = diff.split("\n");
|
|
145
|
+
for (const line of lines) {
|
|
146
|
+
if (line.startsWith("+++ ") || line.startsWith("--- "))
|
|
147
|
+
continue;
|
|
148
|
+
if (line.startsWith("+"))
|
|
149
|
+
additions++;
|
|
150
|
+
else if (line.startsWith("-"))
|
|
151
|
+
deletions++;
|
|
152
|
+
}
|
|
153
|
+
return { additions, deletions };
|
|
154
|
+
}
|
package/package.json
ADDED
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@chronova/pi-plugin",
|
|
3
|
+
"version": "1.0.0",
|
|
4
|
+
"description": "Chronova heartbeat tracking extension for oh-my-pi — sends coding activity to your Chronova dashboard via chronova-cli",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"main": "dist/index.js",
|
|
7
|
+
"files": [
|
|
8
|
+
"dist",
|
|
9
|
+
"README.md",
|
|
10
|
+
"LICENSE"
|
|
11
|
+
],
|
|
12
|
+
"engines": {
|
|
13
|
+
"node": ">=18"
|
|
14
|
+
},
|
|
15
|
+
"private": false,
|
|
16
|
+
"omp": {
|
|
17
|
+
"extensions": [
|
|
18
|
+
"./dist/index.js"
|
|
19
|
+
]
|
|
20
|
+
},
|
|
21
|
+
"scripts": {
|
|
22
|
+
"build": "tsc",
|
|
23
|
+
"prepublishOnly": "npm run build",
|
|
24
|
+
"type-check": "tsc --noEmit",
|
|
25
|
+
"semantic-release": "semantic-release"
|
|
26
|
+
},
|
|
27
|
+
"publishConfig": {
|
|
28
|
+
"access": "public"
|
|
29
|
+
},
|
|
30
|
+
"keywords": [
|
|
31
|
+
"chronova",
|
|
32
|
+
"oh-my-pi",
|
|
33
|
+
"omp",
|
|
34
|
+
"pi-plugin",
|
|
35
|
+
"wakatime",
|
|
36
|
+
"heartbeat",
|
|
37
|
+
"coding-analytics",
|
|
38
|
+
"developer-analytics",
|
|
39
|
+
"ai-coding"
|
|
40
|
+
],
|
|
41
|
+
"license": "MIT",
|
|
42
|
+
"repository": {
|
|
43
|
+
"type": "git",
|
|
44
|
+
"url": "https://github.com/nx-solutions-ug/chronova-pi-plugin.git"
|
|
45
|
+
},
|
|
46
|
+
"homepage": "https://github.com/nx-solutions-ug/chronova-pi-plugin#readme",
|
|
47
|
+
"bugs": {
|
|
48
|
+
"url": "https://github.com/nx-solutions-ug/chronova-pi-plugin/issues"
|
|
49
|
+
},
|
|
50
|
+
"devDependencies": {
|
|
51
|
+
"@oh-my-pi/pi-coding-agent": "^15.1.8",
|
|
52
|
+
"@semantic-release/changelog": "^6.0.3",
|
|
53
|
+
"@semantic-release/commit-analyzer": "^13.0.1",
|
|
54
|
+
"@semantic-release/git": "^10.0.1",
|
|
55
|
+
"@semantic-release/github": "^11.0.3",
|
|
56
|
+
"@semantic-release/npm": "^12.0.2",
|
|
57
|
+
"@semantic-release/release-notes-generator": "^14.0.3",
|
|
58
|
+
"semantic-release": "^24.2.3",
|
|
59
|
+
"typescript": "^5.9.0"
|
|
60
|
+
}
|
|
61
|
+
}
|