Skip to content

Commit e2d07c7

Browse files
authored
Merge branch 'main' into fix/ai-5975-tool-error-propagation
2 parents fe1d8a1 + cfe1bad commit e2d07c7

11 files changed

Lines changed: 257 additions & 48 deletions

File tree

.opencode/skills/data-viz/references/component-guide.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -422,7 +422,7 @@ activateTab('overview'); // init the default visible tab on page load
422422
Library-specific notes:
423423
- **Chart.js**: canvas reads as `0×0` inside `display:none` — bars/lines never appear
424424
- **Recharts `ResponsiveContainer`**: reads `clientWidth = 0` — chart collapses to nothing
425-
- **Nivo `Responsive*`**: uses `ResizeObserver` — fires once at `0×0`, never re-fires on show
425+
- **Nivo `Responsive*`**: uses `ResizeObserver` via `useMeasure`/`useDimensions` in `@nivo/core` — initially measures `0×0` when hidden and skips rendering; re-measures and re-renders correctly when container becomes visible, but the initial blank frame can cause a flash
426426
- **React conditional rendering**: prefer `visibility:hidden` + `position:absolute` over toggling `display:none` if you want charts to stay mounted and pre-rendered
427427

428428
---

docs/docs/reference/security-faq.md

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -126,6 +126,23 @@ Or via environment variable:
126126
export ALTIMATE_TELEMETRY_DISABLED=true
127127
```
128128

129+
### How does Altimate Code identify users for analytics?
130+
131+
- **Logged-in users:** Your email is SHA-256 hashed before sending. We never see your raw email.
132+
- **Anonymous users:** A random UUID (`crypto.randomUUID()`) is generated on first run and stored at `~/.altimate/machine-id`. This is NOT tied to your hardware, OS, or identity — it's purely random.
133+
- **Both identifiers** are only sent when telemetry is enabled. Disable with `ALTIMATE_TELEMETRY_DISABLED=true`.
134+
- **No fingerprinting:** We do not use browser fingerprinting, hardware IDs, MAC addresses, or IP-based tracking.
135+
136+
### What happens on first launch?
137+
138+
A single `first_launch` event is sent containing only:
139+
140+
- The installed version (e.g., "0.5.9")
141+
- Whether this is a fresh install or upgrade (boolean)
142+
- Your anonymous machine ID (random UUID)
143+
144+
No code, queries, file paths, or personal information is included. This event helps us understand adoption and is fully opt-out-able.
145+
129146
## What happens when I authenticate via a well-known URL?
130147

131148
When you run `altimate auth login <url>`, the CLI fetches `<url>/.well-known/altimate-code` to discover the server's auth command. Before executing anything:

docs/docs/reference/telemetry.md

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,7 @@ We collect the following categories of events:
3636
| `skill_used` | A skill is loaded (skill name and source — `builtin`, `global`, or `project` — no skill content) |
3737
| `sql_execute_failure` | A SQL execution fails (warehouse type, query type, error message, PII-masked SQL — no raw values) |
3838
| `core_failure` | An internal tool error occurs (tool name, category, error class, truncated error message, PII-safe input signature, and optionally masked arguments — no raw values or credentials) |
39+
| `first_launch` | Fired once on first CLI run after installation. Contains version and is_upgrade flag. No PII. |
3940

4041
Each event includes a timestamp, anonymous session ID, CLI version, and an anonymous machine ID (a random UUID stored in `~/.altimate/machine-id`, generated once and never tied to any personal information).
4142

@@ -88,6 +89,19 @@ We take your privacy seriously. Altimate Code telemetry **never** collects:
8889

8990
Error messages are truncated to 500 characters and scrubbed of file paths before sending.
9091

92+
### New User Identification
93+
94+
Altimate Code uses two types of anonymous identifiers for analytics, depending on whether you are logged in:
95+
96+
- **Anonymous users (not logged in):** A random UUID is generated using `crypto.randomUUID()` on first run and stored at `~/.altimate/machine-id`. This ID is not tied to your hardware, operating system, or identity — it is purely random and serves only to distinguish one machine from another in aggregate analytics.
97+
- **Logged-in users (OAuth):** Your email address is SHA-256 hashed before sending. The raw email is never transmitted.
98+
99+
Both identifiers are only sent when telemetry is enabled. Disable telemetry entirely with `ALTIMATE_TELEMETRY_DISABLED=true` or the config option above.
100+
101+
### Data Retention
102+
103+
Telemetry data is sent to Azure Application Insights and retained according to [Microsoft's data retention policies](https://learn.microsoft.com/en-us/azure/azure-monitor/logs/data-retention-configure). We do not maintain a separate data store. To request deletion of your telemetry data, contact privacy@altimate.ai.
104+
91105
## Network
92106

93107
Telemetry data is sent to Azure Application Insights:

packages/opencode/src/altimate/telemetry/index.ts

Lines changed: 16 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -350,6 +350,15 @@ export namespace Telemetry {
350350
skill_source: "builtin" | "global" | "project"
351351
duration_ms: number
352352
}
353+
// altimate_change start — first_launch event for new user counting (privacy-safe: only version + machine_id)
354+
| {
355+
type: "first_launch"
356+
timestamp: number
357+
session_id: string
358+
version: string
359+
is_upgrade: boolean
360+
}
361+
// altimate_change end
353362
// altimate_change start — telemetry for skill management operations
354363
| {
355364
type: "skill_created"
@@ -633,7 +642,13 @@ export namespace Telemetry {
633642
iKey: cfg.iKey,
634643
tags: {
635644
"ai.session.id": sid || "startup",
636-
"ai.user.id": userEmail,
645+
// altimate_change start — use machine_id as fallback for anonymous user identification
646+
// This IMPROVES privacy: previously all anonymous users shared ai.user.id=""
647+
// which made them appear as one mega-user in analytics. Using the random UUID
648+
// (already sent as a custom property) gives each machine a distinct identity
649+
// without any PII. machine_id is a crypto.randomUUID() stored locally.
650+
"ai.user.id": userEmail || machineId || "",
651+
// altimate_change end
637652
"ai.cloud.role": "altimate",
638653
"ai.application.ver": Installation.VERSION,
639654
},

packages/opencode/src/cli/cmd/tui/component/tips.tsx

Lines changed: 11 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { createMemo, createSignal, For } from "solid-js"
1+
import { createMemo, For } from "solid-js"
22
import { DEFAULT_THEMES, useTheme } from "@tui/context/theme"
33

44
const themeCount = Object.keys(DEFAULT_THEMES).length
@@ -47,26 +47,31 @@ const BEGINNER_TIPS = [
4747
]
4848
// altimate_change end
4949

50-
// altimate_change start — first-time user beginner tips
50+
// altimate_change start — first-time user beginner tips with reactive pool
5151
export function Tips(props: { isFirstTime?: boolean }) {
5252
const theme = useTheme().theme
53-
const pool = props.isFirstTime ? BEGINNER_TIPS : TIPS
54-
const parts = parse(pool[Math.floor(Math.random() * pool.length)])
55-
// altimate_change end
53+
// Pick random tip index once on mount instead of recalculating randomly when props change
54+
// Use useMemo without dependencies so it only evaluates once
55+
const tipIndex = Math.random()
56+
const tip = createMemo(() => {
57+
const pool = props.isFirstTime ? BEGINNER_TIPS : TIPS
58+
return parse(pool[Math.floor(tipIndex * pool.length)])
59+
})
5660

5761
return (
5862
<box flexDirection="row" maxWidth="100%">
5963
<text flexShrink={0} style={{ fg: theme.warning }}>
6064
● Tip{" "}
6165
</text>
6266
<text flexShrink={1}>
63-
<For each={parts}>
67+
<For each={tip()}>
6468
{(part) => <span style={{ fg: part.highlight ? theme.text : theme.textMuted }}>{part.text}</span>}
6569
</For>
6670
</text>
6771
</box>
6872
)
6973
}
74+
// altimate_change end
7075

7176
const TIPS = [
7277
"Type {highlight}@{/highlight} followed by a filename to fuzzy search and attach files",

packages/opencode/src/cli/cmd/tui/routes/home.tsx

Lines changed: 10 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -38,7 +38,14 @@ export function Home() {
3838
return Object.values(sync.data.mcp).filter((x) => x.status === "connected").length
3939
})
4040

41-
const isFirstTimeUser = createMemo(() => sync.data.session.length === 0)
41+
// altimate_change start — fix race condition: don't show beginner UI until sessions loaded
42+
const isFirstTimeUser = createMemo(() => {
43+
// Don't evaluate until sessions have actually loaded (avoid flash of beginner UI)
44+
// Return undefined to represent "loading" state
45+
if (sync.status === "loading" || sync.status === "partial") return undefined
46+
return sync.data.session.length === 0
47+
})
48+
// altimate_change end
4249
const tipsHidden = createMemo(() => kv.get("tips_hidden", false))
4350
const showTips = createMemo(() => {
4451
// Always show tips — first-time users need guidance the most
@@ -127,7 +134,7 @@ export function Home() {
127134
/>
128135
</box>
129136
{/* altimate_change start — first-time onboarding hint */}
130-
<Show when={isFirstTimeUser()}>
137+
<Show when={isFirstTimeUser() === true}>
131138
<box width="100%" maxWidth={75} paddingTop={1} flexShrink={0}>
132139
<text>
133140
<span style={{ fg: theme.textMuted }}>Get started: </span>
@@ -146,7 +153,7 @@ export function Home() {
146153
<box height={4} minHeight={0} width="100%" maxWidth={75} alignItems="center" paddingTop={3} flexShrink={1}>
147154
<Show when={showTips()}>
148155
{/* altimate_change start — pass first-time flag for beginner tips */}
149-
<Tips isFirstTime={isFirstTimeUser()} />
156+
<Tips isFirstTime={isFirstTimeUser() === true} />
150157
{/* altimate_change end */}
151158
</Show>
152159
</box>

packages/opencode/src/cli/welcome.ts

Lines changed: 22 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,9 @@ import path from "path"
33
import os from "os"
44
import { Installation } from "../installation"
55
import { EOL } from "os"
6+
// altimate_change start — import Telemetry for first_launch event
7+
import { Telemetry } from "../altimate/telemetry"
8+
// altimate_change end
69

710
const APP_NAME = "altimate-code"
811
const MARKER_FILE = ".installed-version"
@@ -36,10 +39,23 @@ export function showWelcomeBannerIfNeeded(): void {
3639
// Remove marker first to avoid showing twice even if display fails
3740
fs.unlinkSync(markerPath)
3841

39-
// altimate_change start — VERSION is already normalized (no "v" prefix)
40-
const currentVersion = Installation.VERSION
42+
// altimate_change start — use ~/.altimate/machine-id existence as a proxy for upgrade vs fresh install
43+
// Since postinstall.mjs always writes the current version to the marker file, we can't reliably
44+
// use installedVersion !== currentVersion for release builds. Instead, if machine-id exists,
45+
// they've run the CLI before.
46+
const machineIdPath = path.join(os.homedir(), ".altimate", "machine-id")
47+
const isUpgrade = fs.existsSync(machineIdPath)
48+
// altimate_change end
49+
50+
// altimate_change start — track first launch for new user counting (privacy-safe: only version + machine_id)
51+
Telemetry.track({
52+
type: "first_launch",
53+
timestamp: Date.now(),
54+
session_id: "",
55+
version: installedVersion,
56+
is_upgrade: isUpgrade,
57+
})
4158
// altimate_change end
42-
const isUpgrade = installedVersion === currentVersion && installedVersion !== "local"
4359

4460
if (!isUpgrade) return
4561

@@ -51,7 +67,9 @@ export function showWelcomeBannerIfNeeded(): void {
5167
const reset = "\x1b[0m"
5268
const bold = "\x1b[1m"
5369

54-
const v = `altimate-code v${currentVersion} installed`
70+
// altimate_change start — use installedVersion (from marker) instead of currentVersion for accurate banner
71+
const v = `altimate-code v${installedVersion} installed`
72+
// altimate_change end
5573
const lines = [
5674
"",
5775
" Get started:",

packages/opencode/src/provider/provider.ts

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1247,6 +1247,9 @@ export namespace Provider {
12471247
return {
12481248
models: languages,
12491249
providers,
1250+
// altimate_change start — expose full provider database (including unauthenticated custom providers)
1251+
database,
1252+
// altimate_change end
12501253
sdk,
12511254
modelLoaders,
12521255
varsLoaders,
@@ -1257,6 +1260,12 @@ export namespace Provider {
12571260
return state().then((state) => state.providers)
12581261
}
12591262

1263+
// altimate_change start — expose full provider database (including unauthenticated custom providers)
1264+
export async function all() {
1265+
return state().then((state) => state.database)
1266+
}
1267+
// altimate_change end
1268+
12601269
async function getSDK(model: Model) {
12611270
try {
12621271
using _ = log.time("getSDK", {

packages/opencode/src/server/routes/provider.ts

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -49,8 +49,19 @@ export const ProviderRoutes = lazy(() =>
4949
}
5050

5151
const connected = await Provider.list()
52+
// altimate_change start — include custom providers (e.g. Snowflake Cortex) even when not yet authenticated
53+
const allDatabase = await Provider.all()
54+
const customProviders: Record<string, Provider.Info> = {}
55+
for (const [key, value] of Object.entries(allDatabase)) {
56+
if (key in filteredProviders || key in connected) continue
57+
if ((enabled ? enabled.has(key) : true) && !disabled.has(key)) {
58+
customProviders[key] = value
59+
}
60+
}
61+
// altimate_change end
5262
const providers = Object.assign(
5363
mapValues(filteredProviders, (x) => Provider.fromModelsDevProvider(x)),
64+
customProviders,
5465
connected,
5566
)
5667
return c.json({

packages/opencode/test/provider/snowflake.test.ts

Lines changed: 108 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -606,3 +606,111 @@ describe("snowflake-cortex provider", () => {
606606
}
607607
})
608608
})
609+
610+
// ---------------------------------------------------------------------------
611+
// Provider.all() — unauthenticated discoverability
612+
// ---------------------------------------------------------------------------
613+
614+
describe("Provider.all() discoverability", () => {
615+
test("includes snowflake-cortex even without oauth auth", async () => {
616+
const savedAuth = await Auth.get("snowflake-cortex")
617+
if (savedAuth) await Auth.remove("snowflake-cortex")
618+
try {
619+
await using tmp = await tmpdir({
620+
init: async (dir) => {
621+
await Bun.write(path.join(dir, "opencode.json"), JSON.stringify({ $schema: "https://altimate.ai/config.json" }))
622+
},
623+
})
624+
await Instance.provide({
625+
directory: tmp.path,
626+
init: async () => {
627+
Env.remove("SNOWFLAKE_ACCOUNT")
628+
},
629+
fn: async () => {
630+
const allProviders = await Provider.all()
631+
expect(allProviders["snowflake-cortex"]).toBeDefined()
632+
expect(allProviders["snowflake-cortex"].name).toBe("Snowflake Cortex")
633+
// list() still returns nothing (not authenticated)
634+
const connected = await Provider.list()
635+
expect(connected["snowflake-cortex"]).toBeUndefined()
636+
},
637+
})
638+
} finally {
639+
if (savedAuth) await Auth.set("snowflake-cortex", savedAuth)
640+
}
641+
})
642+
643+
test("all() includes snowflake-cortex models", async () => {
644+
await using tmp = await tmpdir({
645+
init: async (dir) => {
646+
await Bun.write(path.join(dir, "opencode.json"), JSON.stringify({ $schema: "https://altimate.ai/config.json" }))
647+
},
648+
})
649+
await Instance.provide({
650+
directory: tmp.path,
651+
fn: async () => {
652+
const allProviders = await Provider.all()
653+
const models = allProviders["snowflake-cortex"]?.models
654+
expect(models).toBeDefined()
655+
expect(models["claude-sonnet-4-6"]).toBeDefined()
656+
expect(models["deepseek-r1"]).toBeDefined()
657+
},
658+
})
659+
})
660+
661+
test("disabled_providers config suppresses snowflake-cortex from all()", async () => {
662+
await using tmp = await tmpdir({
663+
init: async (dir) => {
664+
await Bun.write(
665+
path.join(dir, "opencode.json"),
666+
JSON.stringify({ $schema: "https://altimate.ai/config.json", disabled_providers: ["snowflake-cortex"] }),
667+
)
668+
},
669+
})
670+
await Instance.provide({
671+
directory: tmp.path,
672+
fn: async () => {
673+
// Provider.all() returns raw database, config filtering happens at the route level.
674+
// Verify the route-level filtering logic: a disabled provider should not appear
675+
// in the merged provider list used by GET /provider.
676+
const allProviders = await Provider.all()
677+
const connected = await Provider.list()
678+
// Simulate the route filtering (same logic as routes/provider.ts)
679+
const disabled = new Set(["snowflake-cortex"])
680+
const customProviders: Record<string, (typeof allProviders)[string]> = {}
681+
for (const [key, value] of Object.entries(allProviders)) {
682+
if (key in connected) continue
683+
if (!disabled.has(key)) customProviders[key] = value
684+
}
685+
expect(customProviders["snowflake-cortex"]).toBeUndefined()
686+
},
687+
})
688+
})
689+
690+
test("enabled_providers config suppresses snowflake-cortex when not listed", async () => {
691+
await using tmp = await tmpdir({
692+
init: async (dir) => {
693+
await Bun.write(
694+
path.join(dir, "opencode.json"),
695+
JSON.stringify({ $schema: "https://altimate.ai/config.json", enabled_providers: ["anthropic"] }),
696+
)
697+
},
698+
})
699+
await Instance.provide({
700+
directory: tmp.path,
701+
fn: async () => {
702+
const allProviders = await Provider.all()
703+
const connected = await Provider.list()
704+
// Simulate route filtering with enabled_providers
705+
// (snowflake-cortex is not in the enabled list, so it should be excluded)
706+
const enabled = new Set(["anthropic"])
707+
const customProviders: Record<string, (typeof allProviders)[string]> = {}
708+
for (const [key, value] of Object.entries(allProviders)) {
709+
if (key in connected) continue
710+
if (enabled.has(key)) customProviders[key] = value
711+
}
712+
expect(customProviders["snowflake-cortex"]).toBeUndefined()
713+
},
714+
})
715+
})
716+
})

0 commit comments

Comments
 (0)