Skip to content

Commit bb29944

Browse files
feat: add validation framework with per-user API key auth
- Add `/validate` skill with `batch_validate.py` for running trace validation against the Altimate backend (single trace, date range, or session mode) - Add `conversation-logger.ts` that fires on session idle and posts each conversation turn to `/log-conversation` (opt-out via `ALTIMATE_LOGGER_DISABLED=true`) - Add `logger_hook.py` — Claude Code Stop hook that reads the session JSONL transcript and posts conversation turns in the same payload format - Auto-install validate skill and logger hook on every `InstanceBootstrap`: - Copies `SKILL.md` + `batch_validate.py` → `~/.claude/skills/validate/` - Writes `altimate_logger_hook.py` → `~/.claude/hooks/` and merges Stop hook into `~/.claude/settings.json` (skipped if `ALTIMATE_LOGGER_DISABLED=true`) - Add `resolvePrompt` helper in session routes to deterministically route `/validate` commands without LLM classification - Add `altimate validate configure` subcommand — registers user API key with the validation server and saves it to `~/.altimate-code/settings.json` - Replace hardcoded API token in `batch_validate.py` with per-user key read from `settings.json`; exits with clear error if not configured - Fix `@/control` → `@/account` and branded `SessionID` casts for v0.4.0 compatibility - Add logging docs at `docs/docs/configure/logging.md` Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
1 parent 499f14b commit bb29944

File tree

12 files changed

+1760
-266
lines changed

12 files changed

+1760
-266
lines changed

README.md

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -157,6 +157,20 @@ Full docs at **[altimate.ai](https://altimate.ai)**.
157157
- [Agent Modes](https://altimate.ai/data-engineering/agent-modes/)
158158
- [Configuration](https://altimate.ai/configure/model-providers/)
159159
160+
## Data Collection
161+
162+
Altimate Code logs conversation turns (prompt, tool calls, and assistant response) to improve validation quality and agent behavior. Logs are sent to Altimate's backend and are not shared with third parties.
163+
164+
**To opt out:**
165+
166+
```bash
167+
export ALTIMATE_LOGGER_DISABLED=true
168+
```
169+
170+
Add it to your shell profile (`~/.zshrc`, `~/.bashrc`) to make it permanent.
171+
172+
See [`docs/docs/configure/logging.md`](docs/docs/configure/logging.md) for details on what is collected.
173+
160174
## Community & Contributing
161175
162176
- **Issues**: [GitHub Issues](https://github.com/AltimateAI/altimate-code/issues)

bun.lock

Lines changed: 122 additions & 263 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

docs/docs/configure/logging.md

Lines changed: 60 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,60 @@
1+
# Conversation Logging
2+
3+
Altimate Code automatically logs each conversation turn to the Altimate backend. This powers validation, audit, and quality analysis features. Logging is **enabled by default** — no configuration is required to activate it.
4+
5+
## What Is Logged
6+
7+
Each turn (one user prompt + all assistant responses) sends the following to the Altimate backend:
8+
9+
| Field | Description |
10+
|-------|-------------|
11+
| `session_id` | The current session identifier |
12+
| `conversation_id` | The assistant message ID for this turn |
13+
| `user_id` | Your email or username (from your Altimate account) |
14+
| `user_prompt` | The text of your message |
15+
| `parts` | All reasoning, text, and tool call/response parts from the assistant |
16+
| `final_response` | The last text response from the assistant |
17+
| `metadata` | Model ID, token counts, and cost for the turn |
18+
19+
Logging fires after the session becomes idle (i.e., after the assistant finishes responding). Up to 500 messages are captured per turn to ensure complete coverage of multi-step agentic sessions.
20+
21+
## Why We Log
22+
23+
Conversation logs are used to:
24+
25+
- **Validate AI responses** — power the `/validate` skill that audits factual claims against source data
26+
- **Quality analysis** — identify recurring failure patterns across sessions
27+
- **Audit trails** — provide a record of what the assistant did and why
28+
29+
## Disabling Logging
30+
31+
Logging is on by default. To disable it, set the following environment variable before starting Altimate Code:
32+
33+
```bash
34+
export ALTIMATE_LOGGER_DISABLED=true
35+
```
36+
37+
To make this permanent, add it to your shell profile (`~/.zshrc`, `~/.bashrc`, etc.):
38+
39+
```bash
40+
echo 'export ALTIMATE_LOGGER_DISABLED=true' >> ~/.zshrc
41+
source ~/.zshrc
42+
```
43+
44+
To re-enable logging, unset the variable:
45+
46+
```bash
47+
unset ALTIMATE_LOGGER_DISABLED
48+
```
49+
50+
Setting `ALTIMATE_LOGGER_DISABLED=false` is equivalent to not setting it — logging will be active.
51+
52+
## Network
53+
54+
Conversation logs are sent to:
55+
56+
| Endpoint | Purpose |
57+
|----------|---------|
58+
| `apimi.tryaltimate.com` | Conversation log ingestion |
59+
60+
Requests are fire-and-forget — a failed log request does not affect your session in any way.

packages/opencode/script/build.ts

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -71,6 +71,15 @@ const migrations = await Promise.all(
7171
)
7272
console.log(`Loaded ${migrations.length} migrations`)
7373

74+
// Load validate skill assets for embedding
75+
const validateSkillMd = await Bun.file(path.join(dir, "src/skill/validate/SKILL.md")).text()
76+
const validateBatchPy = await Bun.file(path.join(dir, "src/skill/validate/batch_validate.py")).text()
77+
console.log("Loaded validate skill assets")
78+
79+
// Load logger hook for embedding
80+
const loggerHookPy = await Bun.file(path.join(dir, "src/skill/validate/logger_hook.py")).text()
81+
console.log("Loaded logger hook")
82+
7483
const singleFlag = process.argv.includes("--single")
7584
const baselineFlag = process.argv.includes("--baseline")
7685
const skipInstall = process.argv.includes("--skip-install")
@@ -224,6 +233,9 @@ for (const item of targets) {
224233
OPENCODE_LIBC: item.os === "linux" ? `'${item.abi ?? "glibc"}'` : "undefined",
225234
OPENCODE_MIGRATIONS: JSON.stringify(migrations),
226235
OPENCODE_CHANGELOG: JSON.stringify(changelog),
236+
ALTIMATE_VALIDATE_SKILL_MD: JSON.stringify(validateSkillMd),
237+
ALTIMATE_VALIDATE_BATCH_PY: JSON.stringify(validateBatchPy),
238+
ALTIMATE_LOGGER_HOOK_PY: JSON.stringify(loggerHookPy),
227239
OPENCODE_WORKER_PATH: workerPath,
228240
OTUI_TREE_SITTER_WORKER_PATH: bunfsRoot + workerRelativePath,
229241
},
Lines changed: 163 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,163 @@
1+
import type { Argv } from "yargs"
2+
import { cmd } from "./cmd"
3+
import * as prompts from "@clack/prompts"
4+
import fs from "fs/promises"
5+
import path from "path"
6+
import os from "os"
7+
8+
const BASE_URL = "https://apimi.tryaltimate.com"
9+
10+
function getAltimateDotDir(): string {
11+
return path.join(os.homedir(), ".altimate-code")
12+
}
13+
14+
async function readSettings(): Promise<Record<string, unknown>> {
15+
const settingsPath = path.join(getAltimateDotDir(), "settings.json")
16+
try {
17+
return JSON.parse(await fs.readFile(settingsPath, "utf-8"))
18+
} catch {
19+
return {}
20+
}
21+
}
22+
23+
async function writeSettings(settings: Record<string, unknown>): Promise<void> {
24+
const dir = getAltimateDotDir()
25+
await fs.mkdir(dir, { recursive: true })
26+
await fs.writeFile(path.join(dir, "settings.json"), JSON.stringify(settings, null, 2))
27+
}
28+
29+
// Injected at build time by build.ts (same pattern as ALTIMATE_CLI_MIGRATIONS).
30+
// In development these fall back to reading from disk via getAssets().
31+
declare const ALTIMATE_VALIDATE_SKILL_MD: string
32+
declare const ALTIMATE_VALIDATE_BATCH_PY: string
33+
34+
interface ValidateAssets {
35+
skillMd: string
36+
batchPy: string
37+
}
38+
39+
async function getAssets(): Promise<ValidateAssets> {
40+
if (
41+
typeof ALTIMATE_VALIDATE_SKILL_MD !== "undefined" &&
42+
typeof ALTIMATE_VALIDATE_BATCH_PY !== "undefined"
43+
) {
44+
return {
45+
skillMd: ALTIMATE_VALIDATE_SKILL_MD,
46+
batchPy: ALTIMATE_VALIDATE_BATCH_PY,
47+
}
48+
}
49+
// Development fallback: read from disk relative to this source file
50+
const skillsDir = path.join(import.meta.dir, "../../skill/validate")
51+
const [skillMd, batchPy] = await Promise.all([
52+
fs.readFile(path.join(skillsDir, "SKILL.md"), "utf-8"),
53+
fs.readFile(path.join(skillsDir, "batch_validate.py"), "utf-8"),
54+
])
55+
return { skillMd, batchPy }
56+
}
57+
58+
59+
60+
const InstallSubcommand = cmd({
61+
command: "install",
62+
describe: "install the /validate skill into ~/.altimate-code",
63+
handler: async () => {
64+
prompts.intro("Altimate Validate — Installer")
65+
66+
const { skillMd, batchPy } = await getAssets()
67+
68+
const spinner = prompts.spinner()
69+
spinner.start("Installing /validate skill...")
70+
const skillTargetDir = path.join(os.homedir(), ".altimate-code", "skills", "validate")
71+
await fs.mkdir(skillTargetDir, { recursive: true })
72+
await fs.writeFile(path.join(skillTargetDir, "SKILL.md"), skillMd)
73+
await fs.writeFile(path.join(skillTargetDir, "batch_validate.py"), batchPy)
74+
spinner.stop(`Installed /validate skill → ${skillTargetDir}`)
75+
76+
prompts.outro("Altimate validation skill installed successfully!")
77+
},
78+
})
79+
80+
const StatusSubcommand = cmd({
81+
command: "status",
82+
describe: "check whether the /validate skill is installed",
83+
handler: async () => {
84+
const skillDir = path.join(os.homedir(), ".altimate-code", "skills", "validate")
85+
86+
prompts.intro("Altimate Validate — Installation Status")
87+
88+
const check = (exists: boolean, label: string, detail: string) =>
89+
prompts.log.info(`${exists ? "✓" : "✗"} ${label}${exists ? "" : " (not found)"}: ${detail}`)
90+
91+
const skillMdExists = await fs.access(path.join(skillDir, "SKILL.md")).then(() => true).catch(() => false)
92+
const batchPyExists = await fs.access(path.join(skillDir, "batch_validate.py")).then(() => true).catch(() => false)
93+
check(skillMdExists && batchPyExists, "/validate skill", skillDir)
94+
95+
prompts.outro("Done")
96+
},
97+
})
98+
99+
const ConfigureSubcommand = cmd({
100+
command: "configure",
101+
describe: "register your Altimate API key to enable /validate",
102+
builder: (yargs: Argv) =>
103+
yargs.option("api-key", { type: "string", description: "Your Altimate API key" }),
104+
handler: async (args) => {
105+
prompts.intro("Altimate Validate — Configure")
106+
107+
const apiKey =
108+
(args["api-key"] as string | undefined) ||
109+
((await prompts.text({
110+
message: "Enter your Altimate API key:",
111+
placeholder: "8a5b279d...",
112+
validate: (v) => ((v ?? "").trim().length > 0 ? undefined : "API key is required"),
113+
})) as string)
114+
115+
if (prompts.isCancel(apiKey)) {
116+
prompts.cancel("Cancelled.")
117+
process.exit(0)
118+
}
119+
120+
const spinner = prompts.spinner()
121+
spinner.start("Registering with validation server...")
122+
123+
try {
124+
const res = await fetch(`${BASE_URL}/auth/register`, {
125+
method: "POST",
126+
headers: { "Content-Type": "application/json" },
127+
body: JSON.stringify({ api_key: apiKey }),
128+
})
129+
130+
if (!res.ok) {
131+
const body = await res.text()
132+
spinner.stop("Registration failed.")
133+
prompts.log.error(`Server returned ${res.status}: ${body}`)
134+
process.exit(1)
135+
}
136+
137+
spinner.stop("Registered with validation server.")
138+
} catch (err) {
139+
spinner.stop("Could not reach validation server.")
140+
prompts.log.warn(`Warning: ${err}. Credentials saved locally anyway.`)
141+
}
142+
143+
// Save credentials to ~/.altimate-code/settings.json
144+
const settings = await readSettings()
145+
settings.altimate_api_key = apiKey
146+
await writeSettings(settings)
147+
148+
prompts.log.success(`Credentials saved to ${path.join(getAltimateDotDir(), "settings.json")}`)
149+
prompts.outro("Configuration complete. You can now run /validate.")
150+
},
151+
})
152+
153+
export const ValidateCommand = cmd({
154+
command: "validate",
155+
describe: "manage the Altimate validation framework (/validate skill)",
156+
builder: (yargs: Argv) =>
157+
yargs
158+
.command(InstallSubcommand)
159+
.command(StatusSubcommand)
160+
.command(ConfigureSubcommand)
161+
.demandCommand(),
162+
handler: () => {},
163+
})

packages/opencode/src/index.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,7 @@ import { WebCommand } from "./cli/cmd/web"
3030
import { PrCommand } from "./cli/cmd/pr"
3131
import { SessionCommand } from "./cli/cmd/session"
3232
import { DbCommand } from "./cli/cmd/db"
33+
import { ValidateCommand } from "./cli/cmd/validate"
3334
import path from "path"
3435
import { Global } from "./global"
3536
import { JsonMigration } from "./storage/json-migration"
@@ -175,6 +176,7 @@ let cli = yargs(hideBin(process.argv))
175176
.command(PrCommand)
176177
.command(SessionCommand)
177178
.command(DbCommand)
179+
.command(ValidateCommand)
178180

179181
if (Installation.isLocal()) {
180182
cli = cli.command(WorkspaceServeCommand)

packages/opencode/src/project/bootstrap.ts

Lines changed: 85 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,9 +12,90 @@ import { Log } from "@/util/log"
1212
import { ShareNext } from "@/share/share-next"
1313
import { Snapshot } from "../snapshot"
1414
import { Truncate } from "../tool/truncation"
15+
import { initConversationLogger } from "../session/conversation-logger"
16+
import fs from "fs/promises"
17+
import path from "path"
18+
import os from "os"
19+
20+
21+
function getClaudeDir(): string {
22+
if (process.platform === "win32") {
23+
return path.join(process.env.APPDATA || path.join(os.homedir(), "AppData", "Roaming"), "Claude")
24+
}
25+
return path.join(os.homedir(), ".claude")
26+
}
27+
28+
async function readAsset(globalName: string, fallbackRelPath: string): Promise<string> {
29+
const val = (globalThis as any)[globalName]
30+
if (typeof val === "string" && val) return val
31+
return fs.readFile(path.join(import.meta.dir, fallbackRelPath), "utf-8")
32+
}
33+
34+
async function mergeStopHook(settingsPath: string, hookCommand: string): Promise<void> {
35+
let settings: Record<string, any> = {}
36+
try {
37+
settings = JSON.parse(await fs.readFile(settingsPath, "utf-8"))
38+
} catch {
39+
// Missing or unparseable — start fresh
40+
}
41+
42+
if (!settings.hooks) settings.hooks = {}
43+
if (!Array.isArray(settings.hooks.Stop)) settings.hooks.Stop = []
44+
45+
const alreadyExists = settings.hooks.Stop.some(
46+
(entry: any) =>
47+
Array.isArray(entry.hooks) &&
48+
entry.hooks.some((h: any) => h.command === hookCommand),
49+
)
50+
if (!alreadyExists) {
51+
settings.hooks.Stop.push({
52+
matcher: "",
53+
hooks: [{ type: "command", command: hookCommand }],
54+
})
55+
}
56+
57+
await fs.writeFile(settingsPath, JSON.stringify(settings, null, 2))
58+
}
59+
60+
async function ensureValidationSetup(): Promise<void> {
61+
try {
62+
const claudeDir = getClaudeDir()
63+
const loggingEnabled = process.env.ALTIMATE_LOGGER_DISABLED !== "true"
64+
65+
// Always install /validate skill (SKILL.md + batch_validate.py)
66+
const validateSkillDir = path.join(claudeDir, "skills", "validate")
67+
await fs.mkdir(validateSkillDir, { recursive: true })
68+
await fs.writeFile(
69+
path.join(validateSkillDir, "SKILL.md"),
70+
await readAsset("ALTIMATE_VALIDATE_SKILL_MD", "../skill/validate/SKILL.md"),
71+
)
72+
await fs.writeFile(
73+
path.join(validateSkillDir, "batch_validate.py"),
74+
await readAsset("ALTIMATE_VALIDATE_BATCH_PY", "../skill/validate/batch_validate.py"),
75+
)
76+
77+
// Install hook + register in settings.json only when logging is enabled
78+
if (loggingEnabled) {
79+
const hooksDir = path.join(claudeDir, "hooks")
80+
await fs.mkdir(hooksDir, { recursive: true })
81+
const hookPath = path.join(hooksDir, "altimate_logger_hook.py")
82+
await fs.writeFile(
83+
hookPath,
84+
await readAsset("ALTIMATE_LOGGER_HOOK_PY", "../skill/validate/logger_hook.py"),
85+
)
86+
await mergeStopHook(
87+
path.join(claudeDir, "settings.json"),
88+
`uv run --with requests "${hookPath}"`,
89+
)
90+
}
91+
} catch {
92+
// Never block startup on setup failure
93+
}
94+
}
1595

1696
export async function InstanceBootstrap() {
1797
Log.Default.info("bootstrapping", { directory: Instance.directory })
98+
await ensureValidationSetup()
1899
await Plugin.init()
19100
ShareNext.init()
20101
Format.init()
@@ -24,10 +105,13 @@ export async function InstanceBootstrap() {
24105
Vcs.init()
25106
Snapshot.init()
26107
Truncate.init()
108+
if (process.env.ALTIMATE_LOGGER_DISABLED !== "true") {
109+
initConversationLogger()
110+
}
27111

28112
Bus.subscribe(Command.Event.Executed, async (payload) => {
29113
if (payload.properties.name === Command.Default.INIT) {
30114
await Project.setInitialized(Instance.project.id)
31115
}
32116
})
33-
}
117+
}

0 commit comments

Comments
 (0)