claude-b 0.3.2

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.
Binary file
package/bin/cb ADDED
@@ -0,0 +1,3 @@
1
+ #!/usr/bin/env node
2
+
3
+ import('../dist/cli/index.js');
@@ -0,0 +1,156 @@
1
+ #!/usr/bin/env bash
2
+ # Claude Code Stop hook → Claude-B /api/notify → Telegram
3
+ #
4
+ # Fires every time a top-level Claude Code response finishes in any tmux pane.
5
+ # Extracts the last assistant text from the session transcript, tags it with
6
+ # the tmux target (session:window.pane) + pane title (Claude's slug), and
7
+ # POSTs the payload to Claude-B's REST API. Claude-B forwards it to Telegram
8
+ # via the existing bot.broadcastNotification path.
9
+ #
10
+ # Design rules:
11
+ # - NEVER fail the host Claude session. Always exit 0 on any error.
12
+ # - Skip silently if not running inside tmux.
13
+ # - Skip silently if Claude-B daemon / REST / API key is unavailable.
14
+ #
15
+ # Installed as: ~/.claude/settings.json hooks.Stop → this script
16
+ # "hooks": { "Stop": [{ "hooks": [{ "type": "command",
17
+ # "command": "$HOME/Claude-B/bin/cb-notify.sh" }] }] }
18
+
19
+ set +e # tolerate errors — we never want to break the host session
20
+
21
+ CB_URL="${CB_NOTIFY_URL:-http://127.0.0.1:3847/api/notify}"
22
+ CB_KEY_FILE="${CB_API_KEY_FILE:-$HOME/.claude-b/api.key}"
23
+ MAX_RESULT_CHARS=3000
24
+ LOG_FILE="${CB_NOTIFY_LOG:-$HOME/.claude-b/cb-notify.log}"
25
+
26
+ # ─── Read Claude Code hook payload from stdin ───────────────────────────────
27
+ payload=$(cat)
28
+ [[ -z "$payload" ]] && exit 0
29
+
30
+ transcript_path=$(jq -r '.transcript_path // empty' <<<"$payload" 2>/dev/null)
31
+ claude_session_id=$(jq -r '.session_id // empty' <<<"$payload" 2>/dev/null)
32
+ hook_cwd=$(jq -r '.cwd // empty' <<<"$payload" 2>/dev/null)
33
+
34
+ # ─── We only notify for tmux-hosted sessions ────────────────────────────────
35
+ # $TMUX is set by tmux itself for any process running inside a pane.
36
+ if [[ -z "${TMUX:-}" ]]; then
37
+ exit 0
38
+ fi
39
+
40
+ # ─── Derive the tmux target + human label ───────────────────────────────────
41
+ # Prefer $TMUX_PANE (the unique pane id like %42) for lookup, then ask tmux
42
+ # for the stable session:window.pane target and the pane title.
43
+ pane_ref="${TMUX_PANE:-}"
44
+ if [[ -n "$pane_ref" ]]; then
45
+ tmux_target=$(tmux display-message -p -t "$pane_ref" '#S:#I.#P' 2>/dev/null)
46
+ pane_title=$(tmux display-message -p -t "$pane_ref" '#T' 2>/dev/null)
47
+ else
48
+ tmux_target=$(tmux display-message -p '#S:#I.#P' 2>/dev/null)
49
+ pane_title=$(tmux display-message -p '#T' 2>/dev/null)
50
+ fi
51
+
52
+ if [[ -z "$tmux_target" ]]; then
53
+ exit 0
54
+ fi
55
+
56
+ session_label="$tmux_target"
57
+ if [[ -n "$pane_title" ]]; then
58
+ session_label="${tmux_target} ${pane_title}"
59
+ fi
60
+
61
+ # ─── Extract last assistant text from the transcript ────────────────────────
62
+ # Transcript is JSONL where each line is a typed record. We want the most
63
+ # recent assistant message that has at least one text content block. Some
64
+ # assistant turns end with tool_use blocks only (e.g. a final Edit/Bash
65
+ # without commentary); those are skipped backwards until a text-bearing
66
+ # message is found.
67
+ #
68
+ # Small delay: the Stop hook fires right after the response completes but
69
+ # the transcript file may still have unflushed data in kernel/process
70
+ # buffers. Without this, grep can miss the final lines and return an
71
+ # intermediate assistant message instead of the actual summary.
72
+ sleep 0.5
73
+
74
+ last_assistant=""
75
+ if [[ -n "$transcript_path" && -f "$transcript_path" ]]; then
76
+ last_assistant=$(
77
+ grep '"type":"assistant"' "$transcript_path" 2>/dev/null \
78
+ | tac \
79
+ | while IFS= read -r line; do
80
+ text=$(jq -r '
81
+ .message.content // []
82
+ | map(select(.type=="text"))
83
+ | map(.text)
84
+ | join("\n")
85
+ ' <<<"$line" 2>/dev/null)
86
+ if [[ -n "$text" ]]; then
87
+ echo "$text"
88
+ break
89
+ fi
90
+ done
91
+ )
92
+ fi
93
+
94
+ # Grab the most recent turn_duration record if present (Claude writes one
95
+ # system:turn_duration line after each completed assistant turn).
96
+ duration_ms=""
97
+ if [[ -n "$transcript_path" && -f "$transcript_path" ]]; then
98
+ duration_ms=$(
99
+ grep '"subtype":"turn_duration"' "$transcript_path" 2>/dev/null \
100
+ | tail -1 \
101
+ | jq -r '.durationMs // empty' 2>/dev/null
102
+ )
103
+ fi
104
+
105
+ # Fallback body if no assistant message had text at all
106
+ if [[ -z "$last_assistant" ]]; then
107
+ last_assistant="(session completed — no assistant text in transcript)"
108
+ fi
109
+
110
+ # Truncate for the mobile display
111
+ if [[ ${#last_assistant} -gt $MAX_RESULT_CHARS ]]; then
112
+ last_assistant="${last_assistant:0:$MAX_RESULT_CHARS}…"
113
+ fi
114
+
115
+ # ─── Look up API key and POST to /api/notify ────────────────────────────────
116
+ if [[ ! -r "$CB_KEY_FILE" ]]; then
117
+ exit 0
118
+ fi
119
+ api_key=$(<"$CB_KEY_FILE")
120
+ [[ -z "$api_key" ]] && exit 0
121
+
122
+ # Build JSON body safely via jq (handles quoting + newlines).
123
+ # transcriptPath is cached by the daemon so the Telegram voice pipeline can
124
+ # later ground optimizePrompt in real session history.
125
+ body=$(jq -n \
126
+ --arg sessionId "tmux:${tmux_target}" \
127
+ --arg sessionName "$session_label" \
128
+ --arg goal "$hook_cwd" \
129
+ --arg result "$last_assistant" \
130
+ --arg duration "$duration_ms" \
131
+ --arg transcriptPath "$transcript_path" \
132
+ '{
133
+ sessionId: $sessionId,
134
+ sessionName: $sessionName,
135
+ type: "prompt.completed",
136
+ goal: $goal,
137
+ exitCode: 0,
138
+ resultPreview: $result
139
+ }
140
+ + (if $duration != "" then { durationMs: ($duration | tonumber) } else {} end)
141
+ + (if $transcriptPath != "" then { transcriptPath: $transcriptPath } else {} end)')
142
+
143
+ # Fire and forget — short timeout, discard output. Log to a rolling file so
144
+ # the user can debug without cluttering their terminal.
145
+ {
146
+ echo "[$(date -Iseconds)] → $tmux_target (${#last_assistant} chars)"
147
+ curl -sS -m 5 -X POST "$CB_URL" \
148
+ -H 'Content-Type: application/json' \
149
+ -H "X-Claude-B-Key: ${api_key}" \
150
+ --data-binary "$body" 2>&1
151
+ echo
152
+ } >>"$LOG_FILE" 2>&1 &
153
+
154
+ # Detach background curl so we exit immediately — never block host session.
155
+ disown 2>/dev/null || true
156
+ exit 0
@@ -0,0 +1,303 @@
1
+ var __require = /* @__PURE__ */ ((x) => typeof require !== "undefined" ? require : typeof Proxy !== "undefined" ? new Proxy(x, {
2
+ get: (a, b) => (typeof require !== "undefined" ? require : a)[b]
3
+ }) : x)(function(x) {
4
+ if (typeof require !== "undefined") return require.apply(this, arguments);
5
+ throw Error('Dynamic require of "' + x + '" is not supported');
6
+ });
7
+
8
+ // src/config/env.ts
9
+ import { homedir } from "os";
10
+ import { readFileSync, existsSync } from "fs";
11
+ import { join } from "path";
12
+ function parseEnvFile(path) {
13
+ const out = {};
14
+ const raw = readFileSync(path, "utf-8");
15
+ for (const rawLine of raw.split(/\r?\n/)) {
16
+ const line = rawLine.trim();
17
+ if (!line || line.startsWith("#")) continue;
18
+ const eq = line.indexOf("=");
19
+ if (eq === -1) continue;
20
+ const key = line.slice(0, eq).trim();
21
+ if (!/^[A-Z_][A-Z0-9_]*$/i.test(key)) continue;
22
+ let value = line.slice(eq + 1).trim();
23
+ if (value.startsWith('"') && value.endsWith('"') || value.startsWith("'") && value.endsWith("'")) {
24
+ value = value.slice(1, -1);
25
+ }
26
+ out[key] = value;
27
+ }
28
+ return out;
29
+ }
30
+ function loadEnv() {
31
+ const dataDir = process.env.CB_DATA_DIR || join(homedir(), ".claude-b");
32
+ const sources = [
33
+ join(dataDir, ".env"),
34
+ join(process.cwd(), ".env")
35
+ ];
36
+ for (const path of sources) {
37
+ if (!existsSync(path)) continue;
38
+ try {
39
+ const parsed = parseEnvFile(path);
40
+ for (const [key, value] of Object.entries(parsed)) {
41
+ if (process.env[key] === void 0) {
42
+ process.env[key] = value;
43
+ }
44
+ }
45
+ } catch {
46
+ }
47
+ }
48
+ if (process.env.CB_REST_HOST === void 0 && process.env.REST_HOST) {
49
+ process.env.CB_REST_HOST = process.env.REST_HOST;
50
+ }
51
+ if (process.env.CB_REST_PORT === void 0 && process.env.REST_PORT) {
52
+ process.env.CB_REST_PORT = process.env.REST_PORT;
53
+ }
54
+ }
55
+ function getDataDir() {
56
+ return process.env.CB_DATA_DIR || join(homedir(), ".claude-b");
57
+ }
58
+ function envTemplate() {
59
+ return `# Claude-B configuration
60
+ # Precedence: process env > ~/.claude-b/.env > ./.env
61
+ # Uncomment and fill the vars you need.
62
+
63
+ # --- Required for Claude Code ---
64
+ # ANTHROPIC_API_KEY=sk-ant-...
65
+
66
+ # --- Optional: voice pipeline (Telegram voice notes) ---
67
+ # OPENAI_API_KEY=sk-...
68
+ # SPEECHMATICS_API_KEY=
69
+ # DEEPGRAM_API_KEY=
70
+
71
+ # --- Optional: Telegram integration ---
72
+ # TELEGRAM_BOT_TOKEN=123456:ABC...
73
+ # TELEGRAM_ALLOWED_CHAT_IDS=123456789,987654321
74
+
75
+ # --- Optional: Claude-B infrastructure ---
76
+ # CB_DATA_DIR=~/.claude-b
77
+ # CB_REST_HOST=127.0.0.1
78
+ # CB_REST_PORT=3847
79
+ # CB_REST_API_KEY=
80
+ `;
81
+ }
82
+
83
+ // src/utils/claude-detector.ts
84
+ import { existsSync as existsSync2 } from "fs";
85
+ import { execSync } from "child_process";
86
+ import { homedir as homedir2 } from "os";
87
+ import { join as join2 } from "path";
88
+ var cachedInstallation = null;
89
+ var CLAUDE_LOCATIONS = [
90
+ // Native installer (new)
91
+ { path: join2(homedir2(), ".claude", "bin", "claude"), type: "native" },
92
+ // npm global (common)
93
+ { path: join2(homedir2(), ".npm-global", "bin", "claude"), type: "npm-global" },
94
+ // Local bin
95
+ { path: join2(homedir2(), ".local", "bin", "claude"), type: "local" },
96
+ // Homebrew (macOS)
97
+ { path: "/opt/homebrew/bin/claude", type: "homebrew" },
98
+ { path: "/usr/local/bin/claude", type: "homebrew" },
99
+ // Linux system
100
+ { path: "/usr/bin/claude", type: "path" }
101
+ ];
102
+ function detectClaude() {
103
+ if (cachedInstallation) {
104
+ return cachedInstallation;
105
+ }
106
+ const envPath = process.env.CLAUDE_PATH;
107
+ if (envPath && existsSync2(envPath)) {
108
+ cachedInstallation = { path: envPath, type: "env" };
109
+ cachedInstallation.version = getClaudeVersion(envPath);
110
+ return cachedInstallation;
111
+ }
112
+ try {
113
+ const whichResult = execSync("which claude 2>/dev/null", { encoding: "utf-8" }).trim();
114
+ if (whichResult && existsSync2(whichResult)) {
115
+ cachedInstallation = { path: whichResult, type: "path" };
116
+ cachedInstallation.version = getClaudeVersion(whichResult);
117
+ return cachedInstallation;
118
+ }
119
+ } catch {
120
+ }
121
+ for (const location of CLAUDE_LOCATIONS) {
122
+ if (existsSync2(location.path)) {
123
+ cachedInstallation = { path: location.path, type: location.type };
124
+ cachedInstallation.version = getClaudeVersion(location.path);
125
+ return cachedInstallation;
126
+ }
127
+ }
128
+ return null;
129
+ }
130
+ function getClaudeVersion(claudePath) {
131
+ try {
132
+ const result = execSync(`"${claudePath}" --version 2>/dev/null`, { encoding: "utf-8" });
133
+ const match = result.match(/(\d+\.\d+\.\d+)/);
134
+ return match ? match[1] : void 0;
135
+ } catch {
136
+ return void 0;
137
+ }
138
+ }
139
+ function getClaudePath() {
140
+ const installation = detectClaude();
141
+ if (!installation) {
142
+ throw new Error(
143
+ "Claude Code not found. Please install it:\n npm install -g @anthropic-ai/claude-code\nOr set CLAUDE_PATH environment variable to the claude executable path."
144
+ );
145
+ }
146
+ return installation.path;
147
+ }
148
+
149
+ // src/telegram/config.ts
150
+ import { readFile, writeFile, mkdir } from "fs/promises";
151
+ import { existsSync as existsSync3 } from "fs";
152
+ var DEFAULT_CONFIG = {
153
+ token: "",
154
+ enabled: false,
155
+ chatIds: [],
156
+ sessionMap: {},
157
+ pendingPrompts: {},
158
+ resultMap: {}
159
+ };
160
+ var TelegramConfigManager = class {
161
+ configPath;
162
+ configDir;
163
+ config = { ...DEFAULT_CONFIG };
164
+ constructor(configDir) {
165
+ this.configDir = configDir;
166
+ this.configPath = `${configDir}/telegram.json`;
167
+ }
168
+ async load() {
169
+ if (!existsSync3(this.configPath)) {
170
+ this.config = { ...DEFAULT_CONFIG };
171
+ return this.config;
172
+ }
173
+ try {
174
+ const content = await readFile(this.configPath, "utf-8");
175
+ this.config = { ...DEFAULT_CONFIG, ...JSON.parse(content) };
176
+ if (this.config.speechmaticsApiKey && !this.config.sttProvider) {
177
+ this.config.sttProvider = { provider: "speechmatics", apiKey: this.config.speechmaticsApiKey };
178
+ await this.save();
179
+ }
180
+ } catch {
181
+ this.config = { ...DEFAULT_CONFIG };
182
+ }
183
+ return this.config;
184
+ }
185
+ async save() {
186
+ await mkdir(this.configDir, { recursive: true });
187
+ await writeFile(this.configPath, JSON.stringify(this.config, null, 2) + "\n");
188
+ }
189
+ get() {
190
+ return this.config;
191
+ }
192
+ shouldForwardSession() {
193
+ return this.config.forwardAllSessions ?? true;
194
+ }
195
+ async setForwardAllSessions(enabled) {
196
+ this.config.forwardAllSessions = enabled;
197
+ await this.save();
198
+ }
199
+ async setToken(token) {
200
+ this.config.token = token;
201
+ this.config.enabled = true;
202
+ await this.save();
203
+ }
204
+ async disable() {
205
+ this.config.enabled = false;
206
+ this.config.token = "";
207
+ this.config.chatIds = [];
208
+ this.config.sessionMap = {};
209
+ await this.save();
210
+ }
211
+ async addChatId(chatId) {
212
+ if (!this.config.chatIds.includes(chatId)) {
213
+ this.config.chatIds.push(chatId);
214
+ await this.save();
215
+ }
216
+ }
217
+ async mapMessage(telegramMessageId, claudeSessionId) {
218
+ this.config.sessionMap[telegramMessageId] = claudeSessionId;
219
+ const keys = Object.keys(this.config.sessionMap);
220
+ if (keys.length > 500) {
221
+ for (const key of keys.slice(0, keys.length - 500)) {
222
+ delete this.config.sessionMap[key];
223
+ }
224
+ }
225
+ await this.save();
226
+ }
227
+ getSessionForMessage(telegramMessageId) {
228
+ return this.config.sessionMap[telegramMessageId];
229
+ }
230
+ // Voice config
231
+ async setSTTProvider(config) {
232
+ this.config.sttProvider = config;
233
+ if (config.provider === "speechmatics") {
234
+ this.config.speechmaticsApiKey = config.apiKey;
235
+ }
236
+ await this.save();
237
+ }
238
+ /** @deprecated Use setSTTProvider instead */
239
+ async setSpeechmaticsKey(key) {
240
+ await this.setSTTProvider({ provider: "speechmatics", apiKey: key });
241
+ }
242
+ async setAIProvider(config) {
243
+ this.config.aiProvider = config;
244
+ await this.save();
245
+ }
246
+ // Pending prompts (awaiting user confirmation)
247
+ addPendingPrompt(messageId, pending) {
248
+ this.config.pendingPrompts[messageId] = pending;
249
+ const now = Date.now();
250
+ const entries = Object.entries(this.config.pendingPrompts);
251
+ for (const [key, val] of entries) {
252
+ if (now - val.timestamp > 10 * 60 * 1e3) {
253
+ delete this.config.pendingPrompts[key];
254
+ }
255
+ }
256
+ const keys = Object.keys(this.config.pendingPrompts);
257
+ if (keys.length > 50) {
258
+ for (const key of keys.slice(0, keys.length - 50)) {
259
+ delete this.config.pendingPrompts[key];
260
+ }
261
+ }
262
+ }
263
+ getPendingPrompt(messageId) {
264
+ const pending = this.config.pendingPrompts[messageId];
265
+ if (pending && Date.now() - pending.timestamp > 10 * 60 * 1e3) {
266
+ delete this.config.pendingPrompts[messageId];
267
+ return void 0;
268
+ }
269
+ return pending;
270
+ }
271
+ removePendingPrompt(messageId) {
272
+ delete this.config.pendingPrompts[messageId];
273
+ }
274
+ updatePendingPrompt(messageId, optimizedPrompt) {
275
+ const pending = this.config.pendingPrompts[messageId];
276
+ if (pending) {
277
+ pending.optimizedPrompt = optimizedPrompt;
278
+ }
279
+ }
280
+ // Result map (for TTS playback of notification results)
281
+ storeResult(messageId, resultText) {
282
+ this.config.resultMap[messageId] = resultText.slice(0, 5e3);
283
+ const keys = Object.keys(this.config.resultMap);
284
+ if (keys.length > 200) {
285
+ for (const key of keys.slice(0, keys.length - 200)) {
286
+ delete this.config.resultMap[key];
287
+ }
288
+ }
289
+ }
290
+ getResult(messageId) {
291
+ return this.config.resultMap[messageId];
292
+ }
293
+ };
294
+
295
+ export {
296
+ __require,
297
+ loadEnv,
298
+ getDataDir,
299
+ envTemplate,
300
+ detectClaude,
301
+ getClaudePath,
302
+ TelegramConfigManager
303
+ };
@@ -0,0 +1 @@
1
+ #!/usr/bin/env node