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.
- package/README.md +53 -51
- package/bin/free-coding-models.js +429 -4276
- package/package.json +2 -2
- package/sources.js +3 -2
- package/src/account-manager.js +600 -0
- package/src/analysis.js +197 -0
- package/{lib → src}/config.js +122 -0
- package/src/constants.js +116 -0
- package/src/error-classifier.js +154 -0
- package/src/favorites.js +98 -0
- package/src/key-handler.js +1005 -0
- package/src/log-reader.js +174 -0
- package/src/model-merger.js +78 -0
- package/src/openclaw.js +131 -0
- package/src/opencode-sync.js +159 -0
- package/src/opencode.js +952 -0
- package/src/overlays.js +840 -0
- package/src/ping.js +186 -0
- package/src/provider-metadata.js +218 -0
- package/src/provider-quota-fetchers.js +319 -0
- package/src/proxy-server.js +543 -0
- package/src/quota-capabilities.js +112 -0
- package/src/render-helpers.js +239 -0
- package/src/render-table.js +567 -0
- package/src/request-transformer.js +180 -0
- package/src/setup.js +105 -0
- package/src/telemetry.js +382 -0
- package/src/tier-colors.js +37 -0
- package/src/token-stats.js +310 -0
- package/src/token-usage-reader.js +63 -0
- package/src/updater.js +237 -0
- package/src/usage-reader.js +245 -0
- package/{lib → src}/utils.js +55 -0
|
@@ -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
|
+
}
|
package/src/telemetry.js
ADDED
|
@@ -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
|
+
}
|