free-coding-models 0.1.82 → 0.1.84

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.
@@ -0,0 +1,180 @@
1
+ /**
2
+ * request-transformer.js
3
+ *
4
+ * Utilities for transforming outgoing API request bodies before they are
5
+ * forwarded to a model provider:
6
+ * - applyThinkingBudget — control Anthropic-style "thinking" budget
7
+ * - compressContext — reduce prompt size at increasing compression levels
8
+ */
9
+
10
+ // ---------------------------------------------------------------------------
11
+ // Internal helpers
12
+ // ---------------------------------------------------------------------------
13
+
14
+ /**
15
+ * Count the total characters contributed by a single message.
16
+ * Handles both plain-string content and array-of-blocks content.
17
+ *
18
+ * @param {object} msg
19
+ * @returns {number}
20
+ */
21
+ function messageCharCount(msg) {
22
+ if (typeof msg.content === 'string') return msg.content.length
23
+ if (Array.isArray(msg.content)) {
24
+ return msg.content.reduce((sum, block) => {
25
+ if (typeof block === 'string') return sum + block.length
26
+ if (block.type === 'text') return sum + (block.text?.length || 0)
27
+ if (block.type === 'thinking') return sum + (block.thinking?.length || 0)
28
+ return sum
29
+ }, 0)
30
+ }
31
+ return 0
32
+ }
33
+
34
+ // ---------------------------------------------------------------------------
35
+ // applyThinkingBudget
36
+ // ---------------------------------------------------------------------------
37
+
38
+ /**
39
+ * Attach (or omit) an Anthropic-style "thinking" budget to the request body.
40
+ *
41
+ * Modes:
42
+ * 'passthrough' — return a shallow copy of body with no changes
43
+ * 'custom' — add thinking: { budget_tokens: config.budget_tokens }
44
+ * 'auto' — add thinking only when the total prompt is > 2 000 chars;
45
+ * budget is proportional: min(totalChars * 2, 32 000)
46
+ *
47
+ * The original body is NEVER mutated.
48
+ *
49
+ * @param {object} body - The request body (OpenAI-compatible shape)
50
+ * @param {{ mode: string, budget_tokens?: number }} config
51
+ * @returns {object} - A new body object
52
+ */
53
+ export function applyThinkingBudget(body, config) {
54
+ const { mode } = config
55
+
56
+ if (mode === 'passthrough') {
57
+ return { ...body }
58
+ }
59
+
60
+ if (mode === 'custom') {
61
+ return { ...body, thinking: { budget_tokens: config.budget_tokens } }
62
+ }
63
+
64
+ if (mode === 'auto') {
65
+ const messages = Array.isArray(body.messages) ? body.messages : []
66
+ const totalChars = messages.reduce((sum, msg) => {
67
+ if (typeof msg.content === 'string') return sum + msg.content.length
68
+ if (Array.isArray(msg.content)) {
69
+ return sum + msg.content.reduce((s, block) => {
70
+ if (typeof block === 'string') return s + block.length
71
+ return s + (block.text?.length || 0) + (block.thinking?.length || 0)
72
+ }, 0)
73
+ }
74
+ return sum
75
+ }, 0)
76
+
77
+ if (totalChars > 2000) {
78
+ const budget_tokens = Math.min(Math.floor(totalChars * 2), 32000)
79
+ return { ...body, thinking: { budget_tokens } }
80
+ }
81
+
82
+ return { ...body }
83
+ }
84
+
85
+ // Unknown mode — return shallow copy unchanged
86
+ return { ...body }
87
+ }
88
+
89
+ // ---------------------------------------------------------------------------
90
+ // compressContext
91
+ // ---------------------------------------------------------------------------
92
+
93
+ /**
94
+ * Reduce the size of the messages array at increasing compression levels.
95
+ *
96
+ * Levels:
97
+ * 0 — no change (shallow copy of array)
98
+ * 1 — truncate tool-result messages whose content exceeds toolResultMaxChars
99
+ * 2 — L1 + truncate thinking blocks in assistant messages
100
+ * 3 — L2 + drop oldest non-system messages when total chars exceed maxTotalChars
101
+ *
102
+ * The original messages array and its objects are NEVER mutated.
103
+ *
104
+ * @param {object[]} messages
105
+ * @param {{
106
+ * level?: number,
107
+ * toolResultMaxChars?: number,
108
+ * thinkingMaxChars?: number,
109
+ * maxTotalChars?: number
110
+ * }} opts
111
+ * @returns {object[]}
112
+ */
113
+ export function compressContext(messages, opts = {}) {
114
+ const {
115
+ level = 0,
116
+ toolResultMaxChars = 4000,
117
+ thinkingMaxChars = 1000,
118
+ maxTotalChars = 100000,
119
+ } = opts
120
+
121
+ if (level === 0) {
122
+ return [...messages]
123
+ }
124
+
125
+ // L1: trim oversized tool results
126
+ let result = messages.map(msg => {
127
+ if (msg.role === 'tool' && typeof msg.content === 'string') {
128
+ if (msg.content.length > toolResultMaxChars) {
129
+ return {
130
+ ...msg,
131
+ content: msg.content.slice(0, toolResultMaxChars) + '\n[truncated]',
132
+ }
133
+ }
134
+ }
135
+ return msg
136
+ })
137
+
138
+ if (level === 1) {
139
+ return result
140
+ }
141
+
142
+ // L2: trim thinking blocks in assistant messages
143
+ result = result.map(msg => {
144
+ if (msg.role === 'assistant' && Array.isArray(msg.content)) {
145
+ const newContent = msg.content.map(block => {
146
+ if (
147
+ block.type === 'thinking' &&
148
+ typeof block.thinking === 'string' &&
149
+ block.thinking.length > thinkingMaxChars
150
+ ) {
151
+ return { ...block, thinking: block.thinking.slice(0, thinkingMaxChars) }
152
+ }
153
+ return block
154
+ })
155
+ // Only create a new message object when something actually changed
156
+ const changed = newContent.some((b, i) => b !== msg.content[i])
157
+ return changed ? { ...msg, content: newContent } : msg
158
+ }
159
+ return msg
160
+ })
161
+
162
+ if (level === 2) {
163
+ return result
164
+ }
165
+
166
+ // L3: drop oldest non-system messages until total chars is within budget
167
+ // Always preserve: every 'system' message, and the last message in the array.
168
+ const totalChars = () => result.reduce((sum, msg) => sum + messageCharCount(msg), 0)
169
+
170
+ while (totalChars() > maxTotalChars && result.length > 1) {
171
+ // Find the first droppable message: not 'system', not the last one
172
+ const dropIdx = result.findIndex(
173
+ (msg, idx) => msg.role !== 'system' && idx !== result.length - 1
174
+ )
175
+ if (dropIdx === -1) break // nothing left to drop
176
+ result = [...result.slice(0, dropIdx), ...result.slice(dropIdx + 1)]
177
+ }
178
+
179
+ return result
180
+ }
package/src/setup.js ADDED
@@ -0,0 +1,105 @@
1
+ /**
2
+ * @file setup.js
3
+ * @description First-run API key setup wizard, extracted from bin/free-coding-models.js.
4
+ *
5
+ * @details
6
+ * `promptApiKey` is the interactive first-time setup wizard shown when NO provider has
7
+ * a key configured yet. It steps through every configured provider in `sources.js`
8
+ * sequentially, displaying the signup URL and a hint, then asks the user to paste their
9
+ * key (pressing Enter skips that provider).
10
+ *
11
+ * The wizard is skipped on subsequent runs because `loadConfig()` finds existing keys in
12
+ * ~/.free-coding-models.json and the caller (`main()`) only invokes `promptApiKey` when
13
+ * `Object.values(config.apiKeys).every(v => !v)`.
14
+ *
15
+ * ⚙️ How it works:
16
+ * 1. Builds a `providers` list from `Object.keys(sources)` so new providers added to
17
+ * sources.js automatically appear in the wizard without any code changes here.
18
+ * 2. Uses `readline.createInterface` for line-at-a-time input (not raw mode).
19
+ * 3. Calls `saveConfig(config)` once after collecting all answers.
20
+ * 4. Returns the nvidia key (or the first entered key) for backward-compatibility with
21
+ * the `main()` caller that originally checked for `nvidiKey !== null` before continuing.
22
+ *
23
+ * @functions
24
+ * → promptApiKey(config) — Interactive multi-provider key wizard; returns first found key or null
25
+ *
26
+ * @exports
27
+ * promptApiKey
28
+ *
29
+ * @see src/provider-metadata.js — PROVIDER_METADATA provides label/color/url/hint per provider
30
+ * @see src/config.js — saveConfig persists the collected keys
31
+ * @see sources.js — Object.keys(sources) drives the provider iteration order
32
+ * @see bin/free-coding-models.js — calls promptApiKey when no keys are configured
33
+ */
34
+
35
+ import chalk from 'chalk'
36
+ import { createRequire } from 'module'
37
+ import { sources } from '../sources.js'
38
+ import { PROVIDER_METADATA } from './provider-metadata.js'
39
+ import { saveConfig } from './config.js'
40
+
41
+ const require = createRequire(import.meta.url)
42
+ const readline = require('readline')
43
+
44
+ /**
45
+ * 📖 promptApiKey: Interactive first-run wizard for multi-provider API key setup.
46
+ * 📖 Shown when NO provider has a key configured yet.
47
+ * 📖 Steps through all configured providers sequentially — each is optional (Enter to skip).
48
+ * 📖 At least one key must be entered to proceed. Keys saved to ~/.free-coding-models.json.
49
+ * 📖 Returns the nvidia key (or null) for backward-compat with the rest of main().
50
+ * @param {Record<string, unknown>} config
51
+ * @returns {Promise<string|null>}
52
+ */
53
+ export async function promptApiKey(config) {
54
+ console.log()
55
+ console.log(chalk.bold(' 🔑 First-time setup — API keys'))
56
+ console.log(chalk.dim(' Enter keys for any provider you want to use. Press Enter to skip one.'))
57
+ console.log()
58
+
59
+ // 📖 Build providers from sources to keep setup in sync with actual supported providers.
60
+ const providers = Object.keys(sources).map((key) => {
61
+ const meta = PROVIDER_METADATA[key] || {}
62
+ return {
63
+ key,
64
+ label: meta.label || sources[key]?.name || key,
65
+ color: meta.color || chalk.white,
66
+ url: meta.signupUrl || 'https://example.com',
67
+ hint: meta.signupHint || 'Create API key',
68
+ }
69
+ })
70
+
71
+ const rl = readline.createInterface({ input: process.stdin, output: process.stdout })
72
+
73
+ // 📖 Ask a single question — returns trimmed string or '' for skip
74
+ const ask = (question) => new Promise((resolve) => {
75
+ rl.question(question, (answer) => resolve(answer.trim()))
76
+ })
77
+
78
+ for (const p of providers) {
79
+ console.log(` ${p.color('●')} ${chalk.bold(p.label)}`)
80
+ console.log(chalk.dim(` Free key at: `) + chalk.cyanBright(p.url))
81
+ console.log(chalk.dim(` ${p.hint}`))
82
+ const answer = await ask(chalk.dim(` Enter key (or Enter to skip): `))
83
+ console.log()
84
+ if (answer) {
85
+ config.apiKeys[p.key] = answer
86
+ }
87
+ }
88
+
89
+ rl.close()
90
+
91
+ // 📖 Check at least one key was entered
92
+ const anyKey = Object.values(config.apiKeys).some(v => v)
93
+ if (!anyKey) {
94
+ return null
95
+ }
96
+
97
+ saveConfig(config)
98
+ const savedCount = Object.values(config.apiKeys).filter(v => v).length
99
+ console.log(chalk.green(` ✅ ${savedCount} key(s) saved to ~/.free-coding-models.json`))
100
+ console.log(chalk.dim(' You can add or change keys anytime with the ') + chalk.yellow('P') + chalk.dim(' key in the TUI.'))
101
+ console.log()
102
+
103
+ // 📖 Return nvidia key for backward-compat (main() checks it exists before continuing)
104
+ return config.apiKeys.nvidia || Object.values(config.apiKeys).find(v => v) || null
105
+ }
@@ -0,0 +1,382 @@
1
+ /**
2
+ * @file telemetry.js
3
+ * @description Anonymous usage telemetry and Discord feedback webhooks.
4
+ * Extracted from bin/free-coding-models.js to keep the main entry point lean.
5
+ *
6
+ * @details
7
+ * All telemetry is strictly opt-in-by-default, fire-and-forget, and anonymous:
8
+ * - A stable `anonymousId` (UUID prefixed with "anon_") is generated once and stored
9
+ * in ~/.free-coding-models.json. No personal data is ever collected.
10
+ * - PostHog is used for product analytics (app_start events, mode, platform).
11
+ * - Discord webhooks carry anonymous feature requests (J key) and bug reports (I key).
12
+ * - `isTelemetryEnabled()` checks: CLI flag → env var → default (enabled).
13
+ * - `telemetryDebug()` writes to stderr only when FREE_CODING_MODELS_TELEMETRY_DEBUG=1.
14
+ * - `sendUsageTelemetry()` has a hard 1.2 s timeout so it never blocks startup.
15
+ *
16
+ * ⚙️ Configuration (env vars, all optional):
17
+ * - FREE_CODING_MODELS_TELEMETRY=0|false|off — disable telemetry globally
18
+ * - FREE_CODING_MODELS_TELEMETRY_DEBUG=1 — print debug traces to stderr
19
+ * - FREE_CODING_MODELS_POSTHOG_KEY — override the PostHog project key
20
+ * - FREE_CODING_MODELS_POSTHOG_HOST — override the PostHog host
21
+ * - POSTHOG_PROJECT_API_KEY / POSTHOG_HOST — standard PostHog env vars (fallback)
22
+ *
23
+ * @functions
24
+ * → parseTelemetryEnv(value) — Convert env string to boolean or null
25
+ * → isTelemetryDebugEnabled() — Check debug flag from env
26
+ * → telemetryDebug(message, meta) — Conditional debug trace to stderr
27
+ * → ensureTelemetryConfig(config) — Ensure telemetry shape in config object
28
+ * → getTelemetryDistinctId(config) — Get/create stable anonymous ID
29
+ * → getTelemetrySystem() — Convert platform to human label
30
+ * → getTelemetryTerminal() — Infer terminal family from env hints
31
+ * → isTelemetryEnabled(config, cliArgs) — Resolve effective enabled state
32
+ * → sendUsageTelemetry(config, cliArgs, payload)— Fire-and-forget PostHog ping
33
+ * → sendFeatureRequest(message) — Post anonymous feature request to Discord
34
+ * → sendBugReport(message) — Post anonymous bug report to Discord
35
+ *
36
+ * @exports
37
+ * parseTelemetryEnv, isTelemetryDebugEnabled, telemetryDebug,
38
+ * ensureTelemetryConfig, getTelemetryDistinctId,
39
+ * getTelemetrySystem, getTelemetryTerminal,
40
+ * isTelemetryEnabled, sendUsageTelemetry,
41
+ * sendFeatureRequest, sendBugReport
42
+ *
43
+ * @see src/config.js — saveConfig is imported here to persist the generated anonymousId
44
+ * @see bin/free-coding-models.js — calls sendUsageTelemetry on startup and on key events
45
+ */
46
+
47
+ import { randomUUID } from 'crypto'
48
+ import { createRequire } from 'module'
49
+ import { saveConfig } from './config.js'
50
+
51
+ const require = createRequire(import.meta.url)
52
+ const pkg = require('../package.json')
53
+ const LOCAL_VERSION = pkg.version
54
+
55
+ // 📖 PostHog capture endpoint and defaults.
56
+ // 📖 These are public ingest tokens — safe to publish in open-source code.
57
+ const TELEMETRY_TIMEOUT = 1_200
58
+ const POSTHOG_CAPTURE_PATH = '/i/v0/e/'
59
+ const POSTHOG_DEFAULT_HOST = 'https://eu.i.posthog.com'
60
+ const POSTHOG_PROJECT_KEY_DEFAULT = 'phc_5P1n8HaLof6nHM0tKJYt4bV5pj2XPb272fLVigwf1YQ'
61
+ const POSTHOG_HOST_DEFAULT = 'https://eu.i.posthog.com'
62
+
63
+ // 📖 Discord feature request webhook configuration (anonymous feedback system).
64
+ const DISCORD_WEBHOOK_URL = 'https://discord.com/api/webhooks/1476709155992764427/hmnHNtpducvi5LClhv8DynENjUmmg9q8HI1Bx1lNix56UHqrqZf55rW95LGvNJ2W4j7D'
65
+ const DISCORD_BOT_NAME = 'TUI - Feature Requests'
66
+ const DISCORD_EMBED_COLOR = 0x39FF14 // Vert fluo (RGB: 57, 255, 20)
67
+
68
+ // 📖 Discord bug report webhook configuration (anonymous bug reports).
69
+ const DISCORD_BUG_WEBHOOK_URL = 'https://discord.com/api/webhooks/1476715954409963743/5cOLf7U_891f1jwxRBLIp2RIP9xYhr4rWtOhipzKKwVdFVl1Bj89X_fB6I_uGXZiGT9E'
70
+ const DISCORD_BUG_BOT_NAME = 'TUI Bug Report'
71
+ const DISCORD_BUG_EMBED_COLOR = 0xFF5733 // Rouge (RGB: 255, 87, 51)
72
+
73
+ // ─── Helpers ──────────────────────────────────────────────────────────────────
74
+
75
+ /**
76
+ * 📖 parseTelemetryEnv: Convert env var strings into booleans.
77
+ * 📖 Returns true/false when value is recognized, otherwise null.
78
+ * @param {unknown} value
79
+ * @returns {boolean|null}
80
+ */
81
+ export function parseTelemetryEnv(value) {
82
+ if (typeof value !== 'string') return null
83
+ const normalized = value.trim().toLowerCase()
84
+ if (['1', 'true', 'yes', 'on'].includes(normalized)) return true
85
+ if (['0', 'false', 'no', 'off'].includes(normalized)) return false
86
+ return null
87
+ }
88
+
89
+ /**
90
+ * 📖 Optional debug switch for telemetry troubleshooting (disabled by default).
91
+ * @returns {boolean}
92
+ */
93
+ export function isTelemetryDebugEnabled() {
94
+ return parseTelemetryEnv(process.env.FREE_CODING_MODELS_TELEMETRY_DEBUG) === true
95
+ }
96
+
97
+ /**
98
+ * 📖 Writes telemetry debug traces to stderr only when explicitly enabled.
99
+ * @param {string} message
100
+ * @param {unknown} [meta]
101
+ */
102
+ export function telemetryDebug(message, meta = null) {
103
+ if (!isTelemetryDebugEnabled()) return
104
+ const prefix = '[telemetry-debug]'
105
+ if (meta === null) {
106
+ process.stderr.write(`${prefix} ${message}\n`)
107
+ return
108
+ }
109
+ try {
110
+ process.stderr.write(`${prefix} ${message} ${JSON.stringify(meta)}\n`)
111
+ } catch {
112
+ process.stderr.write(`${prefix} ${message}\n`)
113
+ }
114
+ }
115
+
116
+ /**
117
+ * 📖 Ensure telemetry config shape exists even on old config files.
118
+ * @param {Record<string, unknown>} config
119
+ */
120
+ export function ensureTelemetryConfig(config) {
121
+ if (!config.telemetry || typeof config.telemetry !== 'object') {
122
+ config.telemetry = { enabled: true, anonymousId: null }
123
+ }
124
+ // 📖 Only default enabled when unset; do not override a user's explicit opt-out
125
+ if (typeof config.telemetry.enabled !== 'boolean') {
126
+ config.telemetry.enabled = true
127
+ }
128
+ if (typeof config.telemetry.anonymousId !== 'string' || !config.telemetry.anonymousId.trim()) {
129
+ config.telemetry.anonymousId = null
130
+ }
131
+ }
132
+
133
+ /**
134
+ * 📖 Create or reuse a persistent anonymous distinct_id for PostHog.
135
+ * 📖 Stored locally in config so one user is stable over time without personal data.
136
+ * @param {Record<string, unknown>} config
137
+ * @returns {string}
138
+ */
139
+ export function getTelemetryDistinctId(config) {
140
+ ensureTelemetryConfig(config)
141
+ if (config.telemetry.anonymousId) return config.telemetry.anonymousId
142
+
143
+ config.telemetry.anonymousId = `anon_${randomUUID()}`
144
+ saveConfig(config)
145
+ return config.telemetry.anonymousId
146
+ }
147
+
148
+ /**
149
+ * 📖 Convert Node platform to human-readable system name for analytics segmentation.
150
+ * @returns {string}
151
+ */
152
+ export function getTelemetrySystem() {
153
+ if (process.platform === 'darwin') return 'macOS'
154
+ if (process.platform === 'win32') return 'Windows'
155
+ if (process.platform === 'linux') return 'Linux'
156
+ return process.platform
157
+ }
158
+
159
+ /**
160
+ * 📖 Infer terminal family from environment hints for coarse usage segmentation.
161
+ * 📖 Never sends full env dumps; only a normalized terminal label is emitted.
162
+ * @returns {string}
163
+ */
164
+ export function getTelemetryTerminal() {
165
+ const termProgramRaw = (process.env.TERM_PROGRAM || '').trim()
166
+ const termProgram = termProgramRaw.toLowerCase()
167
+ const term = (process.env.TERM || '').toLowerCase()
168
+
169
+ if (termProgram === 'apple_terminal') return 'Terminal.app'
170
+ if (termProgram === 'iterm.app') return 'iTerm2'
171
+ if (termProgram === 'warpterminal' || process.env.WARP_IS_LOCAL_SHELL_SESSION) return 'Warp'
172
+ if (process.env.WT_SESSION) return 'Windows Terminal'
173
+ if (process.env.KITTY_WINDOW_ID || term.includes('kitty')) return 'kitty'
174
+ if (process.env.GHOSTTY_RESOURCES_DIR || term.includes('ghostty')) return 'Ghostty'
175
+ if (process.env.WEZTERM_PANE || termProgram === 'wezterm') return 'WezTerm'
176
+ if (process.env.KONSOLE_VERSION || termProgram === 'konsole') return 'Konsole'
177
+ if (process.env.GNOME_TERMINAL_SCREEN || termProgram === 'gnome-terminal') return 'GNOME Terminal'
178
+ if (process.env.TERMINAL_EMULATOR === 'JetBrains-JediTerm') return 'JetBrains Terminal'
179
+ if (process.env.TABBY_CONFIG_DIRECTORY || termProgram === 'tabby') return 'Tabby'
180
+ if (termProgram === 'vscode' || process.env.VSCODE_GIT_IPC_HANDLE) return 'VS Code Terminal'
181
+ if (process.env.ALACRITTY_SOCKET || term.includes('alacritty') || termProgram === 'alacritty') return 'Alacritty'
182
+ if (term.includes('foot') || termProgram === 'foot') return 'foot'
183
+ if (termProgram === 'hyper' || process.env.HYPER) return 'Hyper'
184
+ if (process.env.TMUX) return 'tmux'
185
+ if (process.env.STY) return 'screen'
186
+ // 📖 Generic fallback for many terminals exposing TERM_PROGRAM (e.g., Rio, Contour, etc.).
187
+ if (termProgramRaw) return termProgramRaw
188
+ if (term) return term
189
+
190
+ return 'unknown'
191
+ }
192
+
193
+ /**
194
+ * 📖 Resolve telemetry effective state with clear precedence:
195
+ * 📖 CLI flag > env var > enabled by default (forced for all users).
196
+ * @param {Record<string, unknown>} config
197
+ * @param {{ noTelemetry?: boolean }} cliArgs
198
+ * @returns {boolean}
199
+ */
200
+ export function isTelemetryEnabled(config, cliArgs) {
201
+ if (cliArgs.noTelemetry) return false
202
+ const envTelemetry = parseTelemetryEnv(process.env.FREE_CODING_MODELS_TELEMETRY)
203
+ if (envTelemetry !== null) return envTelemetry
204
+ ensureTelemetryConfig(config)
205
+ return true
206
+ }
207
+
208
+ /**
209
+ * 📖 Fire-and-forget analytics ping: never blocks UX, never throws.
210
+ * @param {Record<string, unknown>} config
211
+ * @param {{ noTelemetry?: boolean }} cliArgs
212
+ * @param {{ event?: string, version?: string, mode?: string, ts?: string }} payload
213
+ */
214
+ export async function sendUsageTelemetry(config, cliArgs, payload) {
215
+ if (!isTelemetryEnabled(config, cliArgs)) {
216
+ telemetryDebug('skip: telemetry disabled', {
217
+ cliNoTelemetry: cliArgs.noTelemetry === true,
218
+ envTelemetry: process.env.FREE_CODING_MODELS_TELEMETRY || null,
219
+ configEnabled: config?.telemetry?.enabled ?? null,
220
+ })
221
+ return
222
+ }
223
+
224
+ const apiKey = (
225
+ process.env.FREE_CODING_MODELS_POSTHOG_KEY ||
226
+ process.env.POSTHOG_PROJECT_API_KEY ||
227
+ POSTHOG_PROJECT_KEY_DEFAULT ||
228
+ ''
229
+ ).trim()
230
+ if (!apiKey) {
231
+ telemetryDebug('skip: missing api key')
232
+ return
233
+ }
234
+
235
+ const host = (
236
+ process.env.FREE_CODING_MODELS_POSTHOG_HOST ||
237
+ process.env.POSTHOG_HOST ||
238
+ POSTHOG_HOST_DEFAULT ||
239
+ POSTHOG_DEFAULT_HOST
240
+ ).trim().replace(/\/+$/, '')
241
+ if (!host) {
242
+ telemetryDebug('skip: missing host')
243
+ return
244
+ }
245
+
246
+ try {
247
+ const endpoint = `${host}${POSTHOG_CAPTURE_PATH}`
248
+ const distinctId = getTelemetryDistinctId(config)
249
+ const timestamp = typeof payload?.ts === 'string' ? payload.ts : new Date().toISOString()
250
+ const signal = (typeof AbortSignal !== 'undefined' && typeof AbortSignal.timeout === 'function')
251
+ ? AbortSignal.timeout(TELEMETRY_TIMEOUT)
252
+ : undefined
253
+
254
+ const posthogBody = {
255
+ api_key: apiKey,
256
+ event: payload?.event || 'app_start',
257
+ distinct_id: distinctId,
258
+ timestamp,
259
+ properties: {
260
+ $process_person_profile: false,
261
+ source: 'cli',
262
+ app: 'free-coding-models',
263
+ version: payload?.version || LOCAL_VERSION,
264
+ app_version: payload?.version || LOCAL_VERSION,
265
+ mode: payload?.mode || 'opencode',
266
+ system: getTelemetrySystem(),
267
+ terminal: getTelemetryTerminal(),
268
+ },
269
+ }
270
+
271
+ await fetch(endpoint, {
272
+ method: 'POST',
273
+ headers: { 'content-type': 'application/json' },
274
+ body: JSON.stringify(posthogBody),
275
+ signal,
276
+ })
277
+ telemetryDebug('sent', {
278
+ event: posthogBody.event,
279
+ endpoint,
280
+ mode: posthogBody.properties.mode,
281
+ system: posthogBody.properties.system,
282
+ terminal: posthogBody.properties.terminal,
283
+ })
284
+ } catch {
285
+ // 📖 Ignore failures silently: analytics must never break the CLI.
286
+ telemetryDebug('error: send failed')
287
+ }
288
+ }
289
+
290
+ /**
291
+ * 📖 sendFeatureRequest: Send anonymous feature request to Discord via webhook.
292
+ * 📖 Called when user presses J key, types message, and presses Enter.
293
+ * 📖 Returns success/error status for UI feedback.
294
+ * @param {string} message
295
+ * @returns {Promise<{ success: boolean, error: string|null }>}
296
+ */
297
+ export async function sendFeatureRequest(message) {
298
+ try {
299
+ // 📖 Collect anonymous telemetry for context (no personal data)
300
+ const system = getTelemetrySystem()
301
+ const terminal = getTelemetryTerminal()
302
+ const nodeVersion = process.version
303
+ const arch = process.arch
304
+ const timezone = Intl.DateTimeFormat().resolvedOptions().timeZone || 'Unknown'
305
+
306
+ // 📖 Build Discord embed with rich metadata in footer (compact format)
307
+ const embed = {
308
+ description: message,
309
+ color: DISCORD_EMBED_COLOR,
310
+ timestamp: new Date().toISOString(),
311
+ footer: {
312
+ text: `v${LOCAL_VERSION} • ${system} • ${terminal} • ${nodeVersion} • ${arch} • ${timezone}`
313
+ }
314
+ }
315
+
316
+ const response = await fetch(DISCORD_WEBHOOK_URL, {
317
+ method: 'POST',
318
+ headers: { 'content-type': 'application/json' },
319
+ body: JSON.stringify({
320
+ username: DISCORD_BOT_NAME,
321
+ embeds: [embed]
322
+ }),
323
+ signal: AbortSignal.timeout(10000) // 📖 10s timeout for webhook
324
+ })
325
+
326
+ if (!response.ok) {
327
+ throw new Error(`HTTP ${response.status}`)
328
+ }
329
+
330
+ return { success: true, error: null }
331
+ } catch (error) {
332
+ const message = error instanceof Error ? error.message : 'Unknown error'
333
+ return { success: false, error: message }
334
+ }
335
+ }
336
+
337
+ /**
338
+ * 📖 sendBugReport: Send anonymous bug report to Discord via webhook.
339
+ * 📖 Called when user presses I key, types message, and presses Enter.
340
+ * 📖 Returns success/error status for UI feedback.
341
+ * @param {string} message
342
+ * @returns {Promise<{ success: boolean, error: string|null }>}
343
+ */
344
+ export async function sendBugReport(message) {
345
+ try {
346
+ // 📖 Collect anonymous telemetry for context (no personal data)
347
+ const system = getTelemetrySystem()
348
+ const terminal = getTelemetryTerminal()
349
+ const nodeVersion = process.version
350
+ const arch = process.arch
351
+ const timezone = Intl.DateTimeFormat().resolvedOptions().timeZone || 'Unknown'
352
+
353
+ // 📖 Build Discord embed with rich metadata in footer (compact format)
354
+ const embed = {
355
+ description: message,
356
+ color: DISCORD_BUG_EMBED_COLOR,
357
+ timestamp: new Date().toISOString(),
358
+ footer: {
359
+ text: `v${LOCAL_VERSION} • ${system} • ${terminal} • ${nodeVersion} • ${arch} • ${timezone}`
360
+ }
361
+ }
362
+
363
+ const response = await fetch(DISCORD_BUG_WEBHOOK_URL, {
364
+ method: 'POST',
365
+ headers: { 'content-type': 'application/json' },
366
+ body: JSON.stringify({
367
+ username: DISCORD_BUG_BOT_NAME,
368
+ embeds: [embed]
369
+ }),
370
+ signal: AbortSignal.timeout(10000) // 📖 10s timeout for webhook
371
+ })
372
+
373
+ if (!response.ok) {
374
+ throw new Error(`HTTP ${response.status}`)
375
+ }
376
+
377
+ return { success: true, error: null }
378
+ } catch (error) {
379
+ const message = error instanceof Error ? error.message : 'Unknown error'
380
+ return { success: false, error: message }
381
+ }
382
+ }