Skip to content
Open
Show file tree
Hide file tree
Changes from 6 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .opencode/skills/data-viz/references/component-guide.md
Original file line number Diff line number Diff line change
Expand Up @@ -422,7 +422,7 @@ activateTab('overview'); // init the default visible tab on page load
Library-specific notes:
- **Chart.js**: canvas reads as `0×0` inside `display:none` — bars/lines never appear
- **Recharts `ResponsiveContainer`**: reads `clientWidth = 0` — chart collapses to nothing
- **Nivo `Responsive*`**: uses `ResizeObserver` — fires once at `0×0`, never re-fires on show
- **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
- **React conditional rendering**: prefer `visibility:hidden` + `position:absolute` over toggling `display:none` if you want charts to stay mounted and pre-rendered

---
Expand Down
17 changes: 17 additions & 0 deletions docs/docs/reference/security-faq.md
Original file line number Diff line number Diff line change
Expand Up @@ -126,6 +126,23 @@ Or via environment variable:
export ALTIMATE_TELEMETRY_DISABLED=true
```

### How does Altimate Code identify users for analytics?

- **Logged-in users:** Your email is SHA-256 hashed before sending. We never see your raw email.
- **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.
- **Both identifiers** are only sent when telemetry is enabled. Disable with `ALTIMATE_TELEMETRY_DISABLED=true`.
- **No fingerprinting:** We do not use browser fingerprinting, hardware IDs, MAC addresses, or IP-based tracking.

### What happens on first launch?

A single `first_launch` event is sent containing only:

- The installed version (e.g., "0.5.9")
- Whether this is a fresh install or upgrade (boolean)
- Your anonymous machine ID (random UUID)

No code, queries, file paths, or personal information is included. This event helps us understand adoption and is fully opt-out-able.

## What happens when I authenticate via a well-known URL?

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:
Expand Down
14 changes: 14 additions & 0 deletions docs/docs/reference/telemetry.md
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,7 @@ We collect the following categories of events:
| `skill_used` | A skill is loaded (skill name and source — `builtin`, `global`, or `project` — no skill content) |
| `sql_execute_failure` | A SQL execution fails (warehouse type, query type, error message, PII-masked SQL — no raw values) |
| `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) |
| `first_launch` | Fired once on first CLI run after installation. Contains version and is_upgrade flag. No PII. |

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).

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

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

### New User Identification

Altimate Code uses two types of anonymous identifiers for analytics, depending on whether you are logged in:

- **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.
- **Logged-in users (OAuth):** Your email address is SHA-256 hashed before sending. The raw email is never transmitted.

Both identifiers are only sent when telemetry is enabled. Disable telemetry entirely with `ALTIMATE_TELEMETRY_DISABLED=true` or the config option above.

### Data Retention

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 [email protected].

## Network

Telemetry data is sent to Azure Application Insights:
Expand Down
17 changes: 16 additions & 1 deletion packages/opencode/src/altimate/telemetry/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -350,6 +350,15 @@ export namespace Telemetry {
skill_source: "builtin" | "global" | "project"
duration_ms: number
}
// altimate_change start — first_launch event for new user counting (privacy-safe: only version + machine_id)
| {
type: "first_launch"
timestamp: number
session_id: string
version: string
is_upgrade: boolean
}
// altimate_change end
// altimate_change start — telemetry for skill management operations
| {
type: "skill_created"
Expand Down Expand Up @@ -618,7 +627,13 @@ export namespace Telemetry {
iKey: cfg.iKey,
tags: {
"ai.session.id": sid || "startup",
"ai.user.id": userEmail,
// altimate_change start — use machine_id as fallback for anonymous user identification
// This IMPROVES privacy: previously all anonymous users shared ai.user.id=""
// which made them appear as one mega-user in analytics. Using the random UUID
// (already sent as a custom property) gives each machine a distinct identity
// without any PII. machine_id is a crypto.randomUUID() stored locally.
"ai.user.id": userEmail || machineId || "",
// altimate_change end
"ai.cloud.role": "altimate",
"ai.application.ver": Installation.VERSION,
},
Expand Down
17 changes: 11 additions & 6 deletions packages/opencode/src/cli/cmd/tui/component/tips.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { createMemo, createSignal, For } from "solid-js"
import { createMemo, For } from "solid-js"
import { DEFAULT_THEMES, useTheme } from "@tui/context/theme"

const themeCount = Object.keys(DEFAULT_THEMES).length
Expand Down Expand Up @@ -47,26 +47,31 @@ const BEGINNER_TIPS = [
]
// altimate_change end

// altimate_change start — first-time user beginner tips
// altimate_change start — first-time user beginner tips with reactive pool
export function Tips(props: { isFirstTime?: boolean }) {
const theme = useTheme().theme
const pool = props.isFirstTime ? BEGINNER_TIPS : TIPS
const parts = parse(pool[Math.floor(Math.random() * pool.length)])
// altimate_change end
// Pick random tip index once on mount instead of recalculating randomly when props change
// Use useMemo without dependencies so it only evaluates once
const tipIndex = Math.random()
Comment on lines +53 to +55
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

Fix misleading comment: "useMemo" is React terminology, not SolidJS.

The comment references "useMemo" which is a React hook. SolidJS uses createMemo. The approach works correctly (component functions run once in SolidJS), but the comment could confuse maintainers.

📝 Suggested fix
-  // Pick random tip index once on mount instead of recalculating randomly when props change
-  // Use useMemo without dependencies so it only evaluates once
+  // Pick random tip index once on mount instead of recalculating randomly when props change
+  // In SolidJS, component functions only run once, so this constant is stable across re-renders
   const tipIndex = Math.random()
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
// Pick random tip index once on mount instead of recalculating randomly when props change
// Use useMemo without dependencies so it only evaluates once
const tipIndex = Math.random()
// Pick random tip index once on mount instead of recalculating randomly when props change
// In SolidJS, component functions only run once, so this constant is stable across re-renders
const tipIndex = Math.random()
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@packages/opencode/src/cli/cmd/tui/component/tips.tsx` around lines 53 - 55,
The comment above the tipIndex assignment is misleading by referencing React's
useMemo; update the comment to reference SolidJS semantics (or createMemo) or
simply state that Solid component initialization runs once so a random tip can
be selected on mount; locate the tipIndex constant declaration (tipIndex =
Math.random()) in tips.tsx and replace the mention of "useMemo" with either
"createMemo" or a note that no memo hook is required in SolidJS to avoid
confusion for future maintainers.

const tip = createMemo(() => {
const pool = props.isFirstTime ? BEGINNER_TIPS : TIPS
return parse(pool[Math.floor(tipIndex * pool.length)])
})

return (
<box flexDirection="row" maxWidth="100%">
<text flexShrink={0} style={{ fg: theme.warning }}>
● Tip{" "}
</text>
<text flexShrink={1}>
<For each={parts}>
<For each={tip()}>
{(part) => <span style={{ fg: part.highlight ? theme.text : theme.textMuted }}>{part.text}</span>}
</For>
</text>
</box>
)
}
// altimate_change end

const TIPS = [
"Type {highlight}@{/highlight} followed by a filename to fuzzy search and attach files",
Expand Down
13 changes: 10 additions & 3 deletions packages/opencode/src/cli/cmd/tui/routes/home.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -38,7 +38,14 @@ export function Home() {
return Object.values(sync.data.mcp).filter((x) => x.status === "connected").length
})

const isFirstTimeUser = createMemo(() => sync.data.session.length === 0)
// altimate_change start — fix race condition: don't show beginner UI until sessions loaded
const isFirstTimeUser = createMemo(() => {
// Don't evaluate until sessions have actually loaded (avoid flash of beginner UI)
// Return undefined to represent "loading" state
if (sync.status === "loading" || sync.status === "partial") return undefined
return sync.data.session.length === 0
})
// altimate_change end
const tipsHidden = createMemo(() => kv.get("tips_hidden", false))
const showTips = createMemo(() => {
// Always show tips — first-time users need guidance the most
Expand Down Expand Up @@ -127,7 +134,7 @@ export function Home() {
/>
</box>
{/* altimate_change start — first-time onboarding hint */}
<Show when={isFirstTimeUser()}>
<Show when={isFirstTimeUser() === true}>
<box width="100%" maxWidth={75} paddingTop={1} flexShrink={0}>
<text>
<span style={{ fg: theme.textMuted }}>Get started: </span>
Expand All @@ -146,7 +153,7 @@ export function Home() {
<box height={4} minHeight={0} width="100%" maxWidth={75} alignItems="center" paddingTop={3} flexShrink={1}>
<Show when={showTips()}>
{/* altimate_change start — pass first-time flag for beginner tips */}
<Tips isFirstTime={isFirstTimeUser()} />
<Tips isFirstTime={isFirstTimeUser() === true} />
{/* altimate_change end */}
</Show>
</box>
Expand Down
26 changes: 22 additions & 4 deletions packages/opencode/src/cli/welcome.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,9 @@ import path from "path"
import os from "os"
import { Installation } from "../installation"
import { EOL } from "os"
// altimate_change start — import Telemetry for first_launch event
import { Telemetry } from "../altimate/telemetry"
// altimate_change end

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

// altimate_change start — VERSION is already normalized (no "v" prefix)
const currentVersion = Installation.VERSION
// altimate_change start — use ~/.altimate/machine-id existence as a proxy for upgrade vs fresh install
// Since postinstall.mjs always writes the current version to the marker file, we can't reliably
// use installedVersion !== currentVersion for release builds. Instead, if machine-id exists,
// they've run the CLI before.
const machineIdPath = path.join(os.homedir(), ".altimate", "machine-id")
const isUpgrade = fs.existsSync(machineIdPath)
// altimate_change end

// altimate_change start — track first launch for new user counting (privacy-safe: only version + machine_id)
Telemetry.track({
type: "first_launch",
timestamp: Date.now(),
session_id: "",
version: installedVersion,
is_upgrade: isUpgrade,
})
// altimate_change end
const isUpgrade = installedVersion === currentVersion && installedVersion !== "local"

if (!isUpgrade) return

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

const v = `altimate-code v${currentVersion} installed`
// altimate_change start — use installedVersion (from marker) instead of currentVersion for accurate banner
const v = `altimate-code v${installedVersion} installed`
// altimate_change end
const lines = [
"",
" Get started:",
Expand Down
32 changes: 32 additions & 0 deletions packages/opencode/src/file/time.ts
Original file line number Diff line number Diff line change
Expand Up @@ -68,4 +68,36 @@ export namespace FileTime {
)
}
}

/**
* Check if a file has been modified since last read. Instead of throwing,
* returns whether the file was stale and auto-refreshes the read timestamp
* so the caller can re-read contents and proceed.
*
* Returns:
* - { stale: false } if file is up-to-date
* - { stale: true } if file was modified externally (timestamp refreshed)
*
* Still throws if the file was never read in this session.
*/
// altimate_change start — auto-refresh stale files instead of throwing (#450)
export async function assertOrRefresh(
sessionID: string,
filepath: string,
): Promise<{ stale: boolean }> {
if (Flag.OPENCODE_DISABLE_FILETIME_CHECK === true) {
return { stale: false }
}

const time = get(sessionID, filepath)
if (!time) throw new Error(`You must read file ${filepath} before overwriting it. Use the Read tool first`)
const mtime = Filesystem.stat(filepath)?.mtime
if (mtime && mtime.getTime() > time.getTime() + 50) {
log.info("stale file detected, auto-refreshing", { sessionID, filepath })
read(sessionID, filepath)
return { stale: true }
}
return { stale: false }
}
// altimate_change end
}
4 changes: 3 additions & 1 deletion packages/opencode/src/tool/edit.ts
Original file line number Diff line number Diff line change
Expand Up @@ -86,7 +86,9 @@ export const EditTool = Tool.define("edit", {
const stats = Filesystem.stat(filePath)
if (!stats) throw new Error(`File ${filePath} not found`)
if (stats.isDirectory()) throw new Error(`Path is a directory, not a file: ${filePath}`)
await FileTime.assert(ctx.sessionID, filePath)
// altimate_change start — auto-refresh stale files instead of failing (#450)
const { stale } = await FileTime.assertOrRefresh(ctx.sessionID, filePath)
// altimate_change end
contentOld = await Filesystem.readText(filePath)

const ending = detectLineEnding(contentOld)
Expand Down
4 changes: 3 additions & 1 deletion packages/opencode/src/tool/write.ts
Original file line number Diff line number Diff line change
Expand Up @@ -28,8 +28,10 @@ export const WriteTool = Tool.define("write", {
await assertSensitiveWrite(ctx, filepath)

const exists = await Filesystem.exists(filepath)
// altimate_change start — auto-refresh stale files instead of failing (#450)
if (exists) await FileTime.assertOrRefresh(ctx.sessionID, filepath)
// altimate_change end
const contentOld = exists ? await Filesystem.readText(filepath) : ""
if (exists) await FileTime.assert(ctx.sessionID, filepath)

const diff = trimDiff(createTwoFilesPatch(filepath, filepath, contentOld, params.content))
await ctx.ask({
Expand Down
Loading
Loading