honeytree 1.2.0 → 1.2.1

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/src/sync.js ADDED
@@ -0,0 +1,44 @@
1
+ import { getAuth, saveAuth } from "./auth.js";
2
+ import { readForest } from "./state.js";
3
+
4
+ const API_URL = process.env.HONEYTREE_API_URL || "https://tryhoney.xyz";
5
+
6
+ export async function syncToCloud(forest) {
7
+ const auth = getAuth();
8
+ if (!auth || !auth.access_token) return;
9
+
10
+ // Send tree data: type, growth, x position for rendering on the web
11
+ const trees = (forest.trees || []).map((t) => ({
12
+ type: t.type,
13
+ growth: t.growth,
14
+ x: t.x,
15
+ }));
16
+
17
+ try {
18
+ const res = await fetch(`${API_URL}/api/sync`, {
19
+ method: "POST",
20
+ headers: {
21
+ "Content-Type": "application/json",
22
+ Authorization: `Bearer ${auth.access_token}`,
23
+ },
24
+ body: JSON.stringify({
25
+ count: forest.totalPrompts,
26
+ streak: forest.streak || 0,
27
+ trees,
28
+ }),
29
+ });
30
+
31
+ if (res.ok) {
32
+ auth.lastSyncedPrompts = forest.totalPrompts;
33
+ saveAuth(auth);
34
+ }
35
+ } catch {
36
+ // Silently fail — sync is best-effort
37
+ }
38
+ }
39
+
40
+ // Immediate sync (called after login)
41
+ export async function syncNow() {
42
+ const forest = readForest();
43
+ if (forest) await syncToCloud(forest);
44
+ }
@@ -0,0 +1,62 @@
1
+ import fs from "node:fs";
2
+
3
+ function sumOutputTokens(text) {
4
+ let total = 0;
5
+ for (const raw of text.split("\n")) {
6
+ const t = raw.trim();
7
+ if (!t) continue;
8
+ try {
9
+ const obj = JSON.parse(t);
10
+ const ot = obj?.message?.usage?.output_tokens;
11
+ if (typeof ot === "number" && Number.isFinite(ot)) total += ot;
12
+ } catch {
13
+ // ignore malformed line
14
+ }
15
+ }
16
+ return total;
17
+ }
18
+
19
+ function readFrom(filePath, fromOffset, maxBytes = Infinity) {
20
+ let stat;
21
+ try {
22
+ stat = fs.statSync(filePath);
23
+ } catch {
24
+ return null;
25
+ }
26
+ if (stat.size <= fromOffset) return { text: "", size: stat.size };
27
+ const fd = fs.openSync(filePath, "r");
28
+ try {
29
+ const len = Math.min(stat.size - fromOffset, maxBytes);
30
+ const buf = Buffer.alloc(len);
31
+ fs.readSync(fd, buf, 0, len, fromOffset);
32
+ return { text: buf.toString("utf8"), size: stat.size };
33
+ } finally {
34
+ fs.closeSync(fd);
35
+ }
36
+ }
37
+
38
+ // Cap each live tail read so a runaway/unterminated transcript line can't
39
+ // allocate unbounded memory on the 1s viewer interval. The Stop-time full
40
+ // read (readTurnTokens) stays uncapped so a turn is always totalled correctly.
41
+ const MAX_LIVE_READ = 1024 * 1024;
42
+
43
+ // Sum all output tokens from `fromOffset` to EOF (turn is complete at Stop).
44
+ export function readTurnTokens(filePath, fromOffset) {
45
+ const r = readFrom(filePath, fromOffset);
46
+ if (!r) return 0;
47
+ return sumOutputTokens(r.text);
48
+ }
49
+
50
+ // Read only complete (newline-terminated) lines; leave a partial trailing line
51
+ // for the next read by not advancing the offset past it.
52
+ export function readNewTokens(filePath, fromOffset) {
53
+ const r = readFrom(filePath, fromOffset, MAX_LIVE_READ);
54
+ if (!r) return { tokens: 0, newOffset: fromOffset };
55
+ if (!r.text) return { tokens: 0, newOffset: fromOffset };
56
+ const lastNl = r.text.lastIndexOf("\n");
57
+ if (lastNl === -1) return { tokens: 0, newOffset: fromOffset };
58
+ const complete = r.text.slice(0, lastNl + 1);
59
+ const tokens = sumOutputTokens(complete);
60
+ const consumed = Buffer.byteLength(complete, "utf8");
61
+ return { tokens, newOffset: fromOffset + consumed };
62
+ }
package/src/turn.js ADDED
@@ -0,0 +1,54 @@
1
+ import fs from "node:fs";
2
+ import { readForest, createEmptyForest } from "./state.js";
3
+ import { getUnlockedVarietyKeys } from "./rewards.js";
4
+ import { unlockedPool, pickSpecies } from "./varieties.js";
5
+ import { getPlantWidth, findOpenX } from "./plant.js";
6
+ import { writeActiveSession, readActiveSession, clearActiveSession } from "./session.js";
7
+ import { readTurnTokens } from "./transcript.js";
8
+ import { tokensToTree } from "./growth.js";
9
+
10
+ function fileSize(p) {
11
+ try {
12
+ return fs.statSync(p).size;
13
+ } catch {
14
+ return 0;
15
+ }
16
+ }
17
+
18
+ // UserPromptSubmit: mark the start of a turn and pre-choose the tree (type + x)
19
+ // so the live overlay and the committed tree are the same tree.
20
+ export function startTurn(payload) {
21
+ const transcript_path = payload?.transcript_path;
22
+ if (!transcript_path) return;
23
+
24
+ const forest = readForest() ?? createEmptyForest();
25
+ const species = pickSpecies(unlockedPool(getUnlockedVarietyKeys()));
26
+ const type = species.type;
27
+ const variant = species.variant ?? null;
28
+ const width = getPlantWidth(forest);
29
+ const x = findOpenX(forest.trees, type, 1, width);
30
+
31
+ writeActiveSession({
32
+ transcript_path,
33
+ session_id: payload.session_id ?? null,
34
+ turnStartedAt: Date.now(),
35
+ baselineOffset: fileSize(transcript_path),
36
+ type,
37
+ variant,
38
+ x,
39
+ });
40
+ }
41
+
42
+ // Stop: total this turn's output tokens and produce the tree shape, then clear
43
+ // the session. Returns null if there's no matching active session.
44
+ export function computeTickShape(payload) {
45
+ const session = readActiveSession();
46
+ const transcript_path = payload?.transcript_path ?? session?.transcript_path;
47
+ if (!session || !transcript_path || session.transcript_path !== transcript_path) {
48
+ return null;
49
+ }
50
+ const tokens = readTurnTokens(transcript_path, session.baselineOffset || 0);
51
+ const shape = tokensToTree(tokens);
52
+ clearActiveSession();
53
+ return { ...shape, type: session.type, variant: session.variant ?? null, x: session.x, tokens };
54
+ }
@@ -0,0 +1,30 @@
1
+ // Canonical tree-variety definitions for the CLI forest.
2
+ // Each variety maps to one or more renderable "species" specs: { type, variant }.
3
+ // `type` indexes SPRITES in sprites.js; `variant: "ancient"` routes through
4
+ // getAncientSprite (the type is then a base placeholder).
5
+ export const VARIETIES = [
6
+ { key: "standard", threshold: 0, label: "Standard", species: [{ type: "birch" }, { type: "willow" }] },
7
+ { key: "cherry", threshold: 1, label: "Cherry Blossom", species: [{ type: "cherry_blossom" }] },
8
+ { key: "pine", threshold: 5, label: "Pine", species: [{ type: "pine" }] },
9
+ { key: "oak", threshold: 10, label: "Oak", species: [{ type: "oak" }] },
10
+ { key: "ancient", threshold: 25, label: "Ancient", species: [{ type: "oak", variant: "ancient" }] },
11
+ { key: "mythic", threshold: 50, label: "Mythic", species: [{ type: "mythic" }] },
12
+ ];
13
+
14
+ // Build the species pool from the set of unlocked variety keys.
15
+ // `standard` is always included.
16
+ export function unlockedPool(unlockedKeys = []) {
17
+ const keys = new Set(["standard", ...unlockedKeys]);
18
+ const pool = [];
19
+ for (const v of VARIETIES) {
20
+ if (keys.has(v.key)) pool.push(...v.species);
21
+ }
22
+ return pool;
23
+ }
24
+
25
+ const STANDARD_SPECIES = VARIETIES[0].species;
26
+
27
+ export function pickSpecies(pool) {
28
+ const list = Array.isArray(pool) && pool.length > 0 ? pool : STANDARD_SPECIES;
29
+ return list[Math.floor(Math.random() * list.length)];
30
+ }
@@ -0,0 +1,24 @@
1
+ import fs from "node:fs";
2
+ import React from "react";
3
+ import { render } from "ink";
4
+
5
+ import ForestApp from "./components/ForestApp.js";
6
+
7
+ export function createForestWatcher(filePath, onChange) {
8
+ try {
9
+ const stat = fs.statSync(filePath);
10
+ if (!stat.isFile()) return null;
11
+
12
+ const watcher = fs.watch(filePath, onChange);
13
+ watcher.on("error", () => {
14
+ try { watcher.close(); } catch {}
15
+ });
16
+ return watcher;
17
+ } catch {
18
+ return null;
19
+ }
20
+ }
21
+
22
+ export async function viewer() {
23
+ render(React.createElement(ForestApp));
24
+ }
package/src/camera.js DELETED
@@ -1,54 +0,0 @@
1
- // src/camera.js
2
-
3
- const DEG_TO_RAD = Math.PI / 180;
4
-
5
- export function createCamera() {
6
- return {
7
- azimuth: 45,
8
- elevation: 30,
9
- distance: 60,
10
- };
11
- }
12
-
13
- export function rotatePoint(x, y, z, azimuthDeg, elevationDeg) {
14
- const az = azimuthDeg * DEG_TO_RAD;
15
- const el = elevationDeg * DEG_TO_RAD;
16
-
17
- const cosAz = Math.cos(az);
18
- const sinAz = Math.sin(az);
19
- const x1 = x * cosAz + z * sinAz;
20
- const z1 = -x * sinAz + z * cosAz;
21
-
22
- const cosEl = Math.cos(el);
23
- const sinEl = Math.sin(el);
24
- const y1 = y * cosEl - z1 * sinEl;
25
- const z2 = y * sinEl + z1 * cosEl;
26
-
27
- return [x1, y1, z2];
28
- }
29
-
30
- export function projectPoint(x, y, z, screenWidth, screenHeight, distance = 25) {
31
- const fov = 60;
32
- const fovRad = fov * DEG_TO_RAD;
33
- const focalLength = screenHeight / (2 * Math.tan(fovRad / 2));
34
-
35
- const zView = z - distance;
36
-
37
- if (zView >= -1) {
38
- return { screenX: -1, screenY: -1, depth: Infinity, visible: false };
39
- }
40
-
41
- const scale = focalLength / -zView;
42
- const screenX = Math.round(x * scale * 2 + screenWidth / 2);
43
- const screenY = Math.round(-y * scale + screenHeight / 2);
44
-
45
- return { screenX, screenY, depth: -zView, visible: true };
46
- }
47
-
48
- export function clampElevation(elevation) {
49
- return Math.max(10, Math.min(80, elevation));
50
- }
51
-
52
- export function clampAzimuth(azimuth) {
53
- return ((azimuth % 360) + 360) % 360;
54
- }
@@ -1,32 +0,0 @@
1
- import { execSync } from "node:child_process";
2
- import { hunkToPatch } from "./diffparser.js";
3
-
4
- export function stageHunk(rootDir, filePath, hunk) {
5
- try {
6
- const patch = hunkToPatch(filePath, hunk);
7
- execSync("git apply --cached --unidiff-zero -", {
8
- cwd: rootDir,
9
- input: patch,
10
- timeout: 5000,
11
- stdio: ["pipe", "pipe", "pipe"],
12
- });
13
- return { ok: true };
14
- } catch (err) {
15
- return { ok: false, error: err.message };
16
- }
17
- }
18
-
19
- export function revertHunk(rootDir, filePath, hunk) {
20
- try {
21
- const patch = hunkToPatch(filePath, hunk);
22
- execSync("git apply --reverse --unidiff-zero -", {
23
- cwd: rootDir,
24
- input: patch,
25
- timeout: 5000,
26
- stdio: ["pipe", "pipe", "pipe"],
27
- });
28
- return { ok: true };
29
- } catch (err) {
30
- return { ok: false, error: err.message };
31
- }
32
- }
package/src/diffpanel.js DELETED
@@ -1,83 +0,0 @@
1
- // src/diffpanel.js
2
- import chalk from "chalk";
3
-
4
- chalk.level = 3;
5
-
6
- export function createDiffPanel(filePath, hunks) {
7
- return {
8
- filePath,
9
- hunks,
10
- currentHunk: 0,
11
- hunkStatus: hunks.map(() => "pending"),
12
- scrollOffset: 0,
13
- };
14
- }
15
-
16
- export function renderDiffPanel(panel, width, height) {
17
- const lines = [];
18
- const totalAdded = panel.hunks.reduce((s, h) => s + h.added, 0);
19
- const totalRemoved = panel.hunks.reduce((s, h) => s + h.removed, 0);
20
-
21
- // Header
22
- const header = ` ${panel.filePath} +${totalAdded} -${totalRemoved} ${panel.hunks.length} hunks`;
23
- lines.push(chalk.hex("#f5a50b").bold(header.padEnd(width).slice(0, width)));
24
-
25
- // Separator
26
- lines.push(chalk.hex("#555555")("─".repeat(width)));
27
-
28
- // Help line
29
- const help = " j/k: navigate a: accept r: reject A/R: all Esc: close";
30
- lines.push(chalk.hex("#888888")(help.padEnd(width).slice(0, width)));
31
-
32
- lines.push(chalk.hex("#555555")("─".repeat(width)));
33
-
34
- // Render hunks
35
- const bodyHeight = height - lines.length;
36
- const bodyLines = [];
37
-
38
- for (let hi = 0; hi < panel.hunks.length; hi++) {
39
- const hunk = panel.hunks[hi];
40
- const isCurrent = hi === panel.currentHunk;
41
- const status = panel.hunkStatus[hi];
42
-
43
- // Hunk header with status
44
- let statusIcon = "[ ]";
45
- if (status === "accepted") statusIcon = chalk.green("[✓]");
46
- else if (status === "rejected") statusIcon = chalk.red("[✗]");
47
-
48
- const hunkHeader = isCurrent
49
- ? chalk.hex("#ffcc44").bold(`▸ Hunk ${hi + 1}/${panel.hunks.length} ${statusIcon} ${hunk.header}`)
50
- : chalk.hex("#888888")(` Hunk ${hi + 1}/${panel.hunks.length} ${statusIcon} ${hunk.header}`);
51
-
52
- bodyLines.push(hunkHeader.padEnd(width).slice(0, width));
53
-
54
- // Hunk lines
55
- for (const line of hunk.lines) {
56
- let rendered;
57
- const text = line.text.padEnd(width).slice(0, width);
58
- if (line.type === "added") {
59
- rendered = chalk.green(text);
60
- } else if (line.type === "removed") {
61
- rendered = chalk.red(text);
62
- } else {
63
- rendered = isCurrent ? chalk.hex("#cccccc")(text) : chalk.hex("#666666")(text);
64
- }
65
- bodyLines.push(rendered);
66
- }
67
-
68
- bodyLines.push(""); // blank line between hunks
69
- }
70
-
71
- // Apply scroll offset and fill to height
72
- const scrolled = bodyLines.slice(panel.scrollOffset, panel.scrollOffset + bodyHeight);
73
- for (const line of scrolled) {
74
- lines.push(line);
75
- }
76
-
77
- // Pad remaining height
78
- while (lines.length < height) {
79
- lines.push(" ".repeat(width));
80
- }
81
-
82
- return lines.slice(0, height);
83
- }
package/src/diffparser.js DELETED
@@ -1,44 +0,0 @@
1
- export function parseDiff(diffText) {
2
- if (!diffText || !diffText.trim()) return [];
3
-
4
- const lines = diffText.split("\n");
5
- const hunks = [];
6
- let currentHunk = null;
7
-
8
- for (const line of lines) {
9
- if (line.startsWith("@@")) {
10
- if (currentHunk) {
11
- currentHunk.added = currentHunk.lines.filter(l => l.type === "added").length;
12
- currentHunk.removed = currentHunk.lines.filter(l => l.type === "removed").length;
13
- hunks.push(currentHunk);
14
- }
15
- currentHunk = { header: line, lines: [], added: 0, removed: 0 };
16
- } else if (currentHunk) {
17
- if (line.startsWith("+")) {
18
- currentHunk.lines.push({ type: "added", text: line });
19
- } else if (line.startsWith("-")) {
20
- currentHunk.lines.push({ type: "removed", text: line });
21
- } else if (line.startsWith(" ") || line === "") {
22
- currentHunk.lines.push({ type: "context", text: line });
23
- }
24
- }
25
- }
26
-
27
- if (currentHunk) {
28
- currentHunk.added = currentHunk.lines.filter(l => l.type === "added").length;
29
- currentHunk.removed = currentHunk.lines.filter(l => l.type === "removed").length;
30
- hunks.push(currentHunk);
31
- }
32
-
33
- return hunks;
34
- }
35
-
36
- export function hunkToPatch(filePath, hunk) {
37
- const lines = [
38
- `--- a/${filePath}`,
39
- `+++ b/${filePath}`,
40
- hunk.header,
41
- ...hunk.lines.map(l => l.text),
42
- ];
43
- return lines.join("\n") + "\n";
44
- }
package/src/diffwatch.js DELETED
@@ -1,52 +0,0 @@
1
- import { execFile } from "node:child_process";
2
- import fs from "node:fs";
3
- import path from "node:path";
4
-
5
- function execAsync(cmd, args, opts) {
6
- return new Promise((resolve) => {
7
- execFile(cmd, args, { timeout: 5000, encoding: "utf-8", ...opts }, (err, stdout) => {
8
- resolve(err ? "" : stdout);
9
- });
10
- });
11
- }
12
-
13
- export async function getChangedFiles(rootDir) {
14
- const changed = new Set();
15
-
16
- const [tracked, untracked] = await Promise.all([
17
- execAsync("git", ["diff", "--name-only"], { cwd: rootDir }),
18
- execAsync("git", ["ls-files", "--others", "--exclude-standard"], { cwd: rootDir }),
19
- ]);
20
-
21
- for (const output of [tracked, untracked]) {
22
- for (const line of output.split("\n")) {
23
- const trimmed = line.trim();
24
- if (trimmed) changed.add(trimmed);
25
- }
26
- }
27
-
28
- return changed;
29
- }
30
-
31
- export function getFileDiff(rootDir, filePath) {
32
- return execAsync("git", ["diff", "-U3", "--", filePath], { cwd: rootDir });
33
- }
34
-
35
- export function watchForChanges(rootDir, callback) {
36
- let debounceTimer = null;
37
- const debounced = () => {
38
- if (debounceTimer) clearTimeout(debounceTimer);
39
- debounceTimer = setTimeout(callback, 300);
40
- };
41
-
42
- try {
43
- const watcher = fs.watch(rootDir, { recursive: true }, (event, filename) => {
44
- if (!filename) return;
45
- if (filename.includes("node_modules") || filename.includes(".git")) return;
46
- debounced();
47
- });
48
- return watcher;
49
- } catch {
50
- return null;
51
- }
52
- }
package/src/markdown.js DELETED
@@ -1,77 +0,0 @@
1
- import fs from "node:fs";
2
- import path from "node:path";
3
-
4
- import { readForest } from "./state.js";
5
- import { renderPlainText, getWiltFactor } from "./renderer.js";
6
-
7
- function getBiomeLabel(count) {
8
- if (count < 10) return "clearing";
9
- if (count < 25) return "grove";
10
- if (count < 50) return "woodland";
11
- if (count < 100) return "old growth";
12
- return "ancient forest";
13
- }
14
-
15
- function getDayCount(createdAt) {
16
- const created = new Date(createdAt).getTime();
17
- const diff = Date.now() - created;
18
- return Math.max(1, Math.floor(diff / (24 * 60 * 60 * 1000)) + 1);
19
- }
20
-
21
- function buildMarkdown(forest) {
22
- const count = forest.trees.length;
23
- const streak = forest.streak || 0;
24
- const biome = getBiomeLabel(count);
25
- const wilt = getWiltFactor(forest.lastActiveDate);
26
- const days = getDayCount(forest.createdAt);
27
- const width = Math.min(forest.viewerWidth || 60, 80);
28
-
29
- const art = renderPlainText(forest, width);
30
-
31
- const statParts = [`**${count} tree${count === 1 ? "" : "s"}**`];
32
- if (wilt > 0) {
33
- const a = new Date(forest.lastActiveDate + "T00:00:00");
34
- const b = new Date(new Date().toISOString().slice(0, 10) + "T00:00:00");
35
- const idle = Math.round((b - a) / (24 * 60 * 60 * 1000));
36
- statParts.push(`**wilting (${idle}d idle)**`);
37
- } else if (streak > 0) {
38
- statParts.push(`**${streak}-day streak**`);
39
- }
40
- statParts.push(`**${biome}**`);
41
-
42
- const lines = [
43
- `<div align="center">`,
44
- ``,
45
- `[![honeytree](./honeytree-badge.svg)](https://github.com/Varun2009178/honeytree)`,
46
- ``,
47
- statParts.join(" · "),
48
- ``,
49
- "```",
50
- art,
51
- "```",
52
- ``,
53
- `${forest.totalPrompts} prompts over ${days} day${days === 1 ? "" : "s"}`,
54
- ``,
55
- `<sub>Grown with <a href="https://github.com/Varun2009178/honeytree">honeytree</a> — a forest that grows in your terminal every time you use Claude Code</sub>`,
56
- ``,
57
- `</div>`,
58
- ``,
59
- ];
60
-
61
- return lines.join("\n");
62
- }
63
-
64
- export async function generateForestMd() {
65
- const forest = readForest();
66
- if (!forest) {
67
- console.error('No forest found. Run "honeytree init" first.');
68
- process.exit(1);
69
- }
70
-
71
- const md = buildMarkdown(forest);
72
- const outPath = path.resolve("FOREST.md");
73
- fs.writeFileSync(outPath, md);
74
-
75
- console.log(`Written to ${outPath}`);
76
- console.log("Tip: run `honeytree badge` to generate the badge SVG too.");
77
- }