@onyx-robotics/agent 0.1.0

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,88 @@
1
+ import type { Args } from "../lib/args"
2
+ import { requestProjectSync, resolveProject } from "../lib/api"
3
+ import { emitEvent } from "../lib/events"
4
+ import { currentBranch, pushBranch, repoRoot } from "../lib/git"
5
+ import { hydrateHistoryFromApi } from "../lib/history"
6
+ import { readLastRun, readOutbox } from "../lib/outbox"
7
+ import { resolveProjectPath } from "../lib/project"
8
+ import { flushOutbox } from "../lib/sync"
9
+
10
+ export async function commandPush(args: Args) {
11
+ const root = await repoRoot()
12
+ const branchName = await currentBranch(root)
13
+ if (!branchName) throw new Error("Cannot push from a detached HEAD.")
14
+ await pushBranch(root, branchName)
15
+ await emitEvent(root, { type: "pushed", message: branchName })
16
+ console.log(`Pushed ${branchName}`)
17
+ await flushOutbox(root, args)
18
+ }
19
+
20
+ export async function commandSync(args: Args) {
21
+ const root = await repoRoot()
22
+ const result = await flushOutbox(root, args)
23
+ if (result.offline) return
24
+
25
+ // Refresh the local history cache to the canonical API state. Runs even
26
+ // when the outbox was empty so fresh clones pick up cross-branch history.
27
+ try {
28
+ const hydrated = await hydrateHistoryFromApi(root, args)
29
+ console.log(
30
+ `history: ${hydrated.experiments} experiment(s) across ${hydrated.branches} branch(es)${
31
+ hydrated.pendingLocal ? `, ${hydrated.pendingLocal} pending local` : ""
32
+ }`
33
+ )
34
+ } catch (error) {
35
+ console.warn(
36
+ `History refresh skipped: ${
37
+ error instanceof Error ? error.message : String(error)
38
+ }`
39
+ )
40
+ }
41
+
42
+ // Warm the server mirror so file/diff views stay fast.
43
+ try {
44
+ const project = await resolveProject(root, args)
45
+ await requestProjectSync(project.id, args)
46
+ console.log(`Requested mirror refresh for project ${project.id}`)
47
+ } catch (error) {
48
+ console.warn(
49
+ `Mirror refresh skipped: ${
50
+ error instanceof Error ? error.message : String(error)
51
+ }`
52
+ )
53
+ }
54
+ }
55
+
56
+ export async function commandStatus(args: Args) {
57
+ const root = await repoRoot()
58
+ const projectPath = await resolveProjectPath(root, args)
59
+ const branchName = await currentBranch(root)
60
+ const { records, corrupt } = await readOutbox(root)
61
+ const lastRun = await readLastRun(root)
62
+ const experiments = records.filter(
63
+ (record) => record.type === "experiment_logged"
64
+ ).length
65
+ const branches = records.filter(
66
+ (record) => record.type === "branch_started"
67
+ ).length
68
+
69
+ console.log(`branch: ${branchName || "(detached)"}`)
70
+ console.log(`projectPath: ${projectPath || "(repo root)"}`)
71
+ console.log(
72
+ `outbox: ${experiments} experiment(s), ${branches} branch(es) pending${
73
+ corrupt ? `, ${corrupt} unreadable` : ""
74
+ }`
75
+ )
76
+ if (lastRun) {
77
+ console.log(
78
+ `last run: ${lastRun.commitSha.slice(0, 7)} (${lastRun.status}, ${lastRun.primaryMetricName}=${lastRun.primaryMetricValue ?? "null"})`
79
+ )
80
+ }
81
+
82
+ try {
83
+ const project = await resolveProject(root, args)
84
+ console.log(`project: ${project.name} (${project.id})`)
85
+ } catch {
86
+ console.log("project: not linked / offline")
87
+ }
88
+ }
@@ -0,0 +1,38 @@
1
+ import { describe, expect, test } from "bun:test"
2
+
3
+ async function dryRun(os: string, arch: string) {
4
+ const process = Bun.spawn(["sh", "scripts/install.sh"], {
5
+ cwd: import.meta.dir + "/..",
6
+ env: {
7
+ ...Bun.env,
8
+ ONYX_INSTALL_DRY_RUN: "1",
9
+ ONYX_INSTALL_OS: os,
10
+ ONYX_INSTALL_ARCH: arch,
11
+ },
12
+ stdout: "pipe",
13
+ stderr: "pipe",
14
+ })
15
+ const stdout = await new Response(process.stdout).text()
16
+ const stderr = await new Response(process.stderr).text()
17
+ const code = await process.exited
18
+ if (code !== 0) {
19
+ throw new Error(stderr || stdout)
20
+ }
21
+ return stdout
22
+ }
23
+
24
+ describe("install script", () => {
25
+ test("maps macOS arm64 to the darwin arm64 asset", async () => {
26
+ const output = await dryRun("Darwin", "arm64")
27
+
28
+ expect(output).toContain("target=darwin-arm64")
29
+ expect(output).toContain("asset=onyx-darwin-arm64")
30
+ })
31
+
32
+ test("maps Linux x64 to the baseline asset", async () => {
33
+ const output = await dryRun("Linux", "x86_64")
34
+
35
+ expect(output).toContain("target=linux-x64-baseline")
36
+ expect(output).toContain("asset=onyx-linux-x64-baseline")
37
+ })
38
+ })
package/src/lib/api.ts ADDED
@@ -0,0 +1,227 @@
1
+ import type {
2
+ CreateResearchExperimentRequest,
3
+ CreateResearchBranchRequest,
4
+ } from "../protocol"
5
+
6
+ import type { Args } from "./args"
7
+ import { apiBaseUrl, apiKey } from "./config"
8
+ import { normalizeRepositoryUrl, repositoryUrl } from "./git"
9
+ import { resolveProjectPath } from "./project"
10
+
11
+ export type ApiProject = {
12
+ id: string
13
+ name: string
14
+ repositoryUrl: string
15
+ repositoryFullName: string | null
16
+ defaultBranch: string
17
+ projectPath: string
18
+ }
19
+
20
+ export type ApiBranch = {
21
+ id: string
22
+ name: string
23
+ gitBranchName: string | null
24
+ }
25
+
26
+ export type ApiExperiment = {
27
+ id: string
28
+ sequenceNumber: number
29
+ runRef: string
30
+ commitSha: string
31
+ status: string
32
+ primaryMetricName: string
33
+ primaryMetricValue: number | null
34
+ }
35
+
36
+ export type ApiTreeExperiment = ApiExperiment & {
37
+ branchId: string
38
+ name: string
39
+ description: string | null
40
+ secondaryMetrics: Record<string, unknown>
41
+ agentNotes: Record<string, unknown>
42
+ checks: {
43
+ status: "passed" | "failed" | "timed_out"
44
+ durationMs: number | null
45
+ outputSummary: string | null
46
+ } | null
47
+ durationMs: number | null
48
+ outputSummary: string | null
49
+ startedAt: string | null
50
+ completedAt: string | null
51
+ createdAt: string
52
+ }
53
+
54
+ export type ApiTreeBranch = ApiBranch & {
55
+ experiments: ApiTreeExperiment[]
56
+ }
57
+
58
+ export type ApiProjectTree = {
59
+ project: ApiProject
60
+ branches: ApiTreeBranch[]
61
+ }
62
+
63
+ export async function callApi(
64
+ method: string,
65
+ path: string,
66
+ body?: unknown,
67
+ args?: Args
68
+ ) {
69
+ const response = await fetch(`${await apiBaseUrl(args)}${path}`, {
70
+ method,
71
+ headers: {
72
+ "content-type": "application/json",
73
+ authorization: `Bearer ${await apiKey(args)}`,
74
+ },
75
+ body: body ? JSON.stringify(body) : undefined,
76
+ })
77
+
78
+ const text = await response.text()
79
+ let payload: unknown = null
80
+ try {
81
+ payload = text ? JSON.parse(text) : null
82
+ } catch {
83
+ payload = text
84
+ }
85
+
86
+ if (!response.ok) {
87
+ throw new ApiError(method, path, response.status, payload)
88
+ }
89
+
90
+ return payload
91
+ }
92
+
93
+ export class ApiError extends Error {
94
+ status: number
95
+
96
+ constructor(method: string, path: string, status: number, payload: unknown) {
97
+ super(
98
+ `${method} ${path} failed (${status}): ${
99
+ typeof payload === "string" ? payload : JSON.stringify(payload)
100
+ }`
101
+ )
102
+ this.name = "ApiError"
103
+ this.status = status
104
+ }
105
+ }
106
+
107
+ export function apiData<T>(payload: unknown): T {
108
+ if (!payload || typeof payload !== "object" || !("data" in payload)) {
109
+ throw new Error(`Unexpected API response: ${JSON.stringify(payload)}`)
110
+ }
111
+ return (payload as { data: T }).data
112
+ }
113
+
114
+ /**
115
+ * Resolves the linked Onyx project for this repository by matching the origin
116
+ * URL + projectPath against the team's projects (or an explicit --project id).
117
+ * Throws when nothing matches so callers can keep work queued in the outbox.
118
+ */
119
+ export async function resolveProject(
120
+ root: string,
121
+ args: Args
122
+ ): Promise<ApiProject> {
123
+ const projectPath = await resolveProjectPath(root, args)
124
+ const projects = apiData<ApiProject[]>(
125
+ await callApi("GET", "/api/v1/research/projects", undefined, args)
126
+ )
127
+
128
+ if (args.options.project) {
129
+ const byId = projects.find(
130
+ (candidate) => candidate.id === args.options.project
131
+ )
132
+ if (byId) return byId
133
+ return {
134
+ id: args.options.project,
135
+ name: args.options.project,
136
+ repositoryUrl: "",
137
+ repositoryFullName: null,
138
+ defaultBranch: "main",
139
+ projectPath,
140
+ }
141
+ }
142
+
143
+ const url = normalizeRepositoryUrl(
144
+ await repositoryUrl(root, args.options["repository-url"])
145
+ )
146
+ const project = projects.find(
147
+ (candidate) =>
148
+ normalizeRepositoryUrl(candidate.repositoryUrl) === url &&
149
+ candidate.projectPath === projectPath
150
+ )
151
+
152
+ if (!project) {
153
+ throw new Error(
154
+ "No linked Onyx project matched this repository. Link the repo in Onyx first."
155
+ )
156
+ }
157
+
158
+ return project
159
+ }
160
+
161
+ export async function listProjectBranches(
162
+ projectId: string,
163
+ args?: Args
164
+ ): Promise<ApiBranch[]> {
165
+ return apiData<ApiBranch[]>(
166
+ await callApi(
167
+ "GET",
168
+ `/api/v1/research/projects/${projectId}/branches`,
169
+ undefined,
170
+ args
171
+ )
172
+ )
173
+ }
174
+
175
+ /** Full project hierarchy (branches + experiments) for history hydration. */
176
+ export async function getProjectTree(
177
+ projectId: string,
178
+ args?: Args
179
+ ): Promise<ApiProjectTree> {
180
+ return apiData<ApiProjectTree>(
181
+ await callApi(
182
+ "GET",
183
+ `/api/v1/research/projects/${projectId}/tree`,
184
+ undefined,
185
+ args
186
+ )
187
+ )
188
+ }
189
+
190
+ export async function upsertBranch(
191
+ projectId: string,
192
+ body: CreateResearchBranchRequest,
193
+ args?: Args
194
+ ): Promise<ApiBranch> {
195
+ return apiData<ApiBranch>(
196
+ await callApi(
197
+ "POST",
198
+ `/api/v1/research/projects/${projectId}/branches`,
199
+ body,
200
+ args
201
+ )
202
+ )
203
+ }
204
+
205
+ export async function reportExperiment(
206
+ branchId: string,
207
+ body: CreateResearchExperimentRequest,
208
+ args?: Args
209
+ ): Promise<ApiExperiment> {
210
+ return apiData<ApiExperiment>(
211
+ await callApi(
212
+ "POST",
213
+ `/api/v1/research/branches/${branchId}/experiments`,
214
+ body,
215
+ args
216
+ )
217
+ )
218
+ }
219
+
220
+ export async function requestProjectSync(projectId: string, args?: Args) {
221
+ await callApi(
222
+ "POST",
223
+ `/api/v1/research/projects/${projectId}/sync`,
224
+ undefined,
225
+ args
226
+ )
227
+ }
@@ -0,0 +1,68 @@
1
+ export type Args = {
2
+ positional: string[]
3
+ options: Record<string, string>
4
+ }
5
+
6
+ export function parseArgs(argv: string[]): Args {
7
+ const positional: string[] = []
8
+ const options: Record<string, string> = {}
9
+
10
+ for (let i = 0; i < argv.length; i++) {
11
+ const token = argv[i]!
12
+ if (!token.startsWith("--")) {
13
+ positional.push(token)
14
+ continue
15
+ }
16
+
17
+ const key = token.slice(2)
18
+ const eq = key.indexOf("=")
19
+
20
+ if (eq !== -1) {
21
+ options[key.slice(0, eq)] = key.slice(eq + 1)
22
+ continue
23
+ }
24
+
25
+ const next = argv[i + 1]
26
+ if (next === undefined || next.startsWith("--")) {
27
+ options[key] = "true"
28
+ } else {
29
+ options[key] = next
30
+ i += 1
31
+ }
32
+ }
33
+
34
+ return { positional, options }
35
+ }
36
+
37
+ export function requireOption(args: Args, name: string): string {
38
+ const value = args.options[name]
39
+ if (!value) {
40
+ throw new Error(`Missing required --${name}`)
41
+ }
42
+ return value
43
+ }
44
+
45
+ export function optionalFlag(args: Args, name: string) {
46
+ return args.options[name] === "true"
47
+ }
48
+
49
+ export function normalizeName(value: string) {
50
+ return value
51
+ .toLowerCase()
52
+ .trim()
53
+ .replace(/[^a-z0-9]+/g, "-")
54
+ .replace(/^-+|-+$/g, "")
55
+ }
56
+
57
+ export function nameOption(args: Args) {
58
+ const value = args.options.name
59
+ if (!value) throw new Error("Missing required --name")
60
+ const name = normalizeName(value)
61
+ if (!name)
62
+ throw new Error("--name must contain at least one letter or number")
63
+ return name
64
+ }
65
+
66
+ export function descriptionOption(args: Args): string | null {
67
+ return args.options.description ?? null
68
+ }
@@ -0,0 +1,148 @@
1
+ import { mkdir, readFile, writeFile } from "node:fs/promises"
2
+ import { homedir } from "node:os"
3
+ import { join } from "node:path"
4
+
5
+ import type { Args } from "./args"
6
+
7
+ const CONFIG_FILE = "config.json"
8
+ export const DEFAULT_API_URL = "https://app.onyxresearch.ai"
9
+
10
+ export type CliProfile = {
11
+ apiUrl: string
12
+ apiKey?: string
13
+ apiKeyId?: string
14
+ apiKeyEnv?: string
15
+ teamId: string
16
+ teamName: string
17
+ updatedAt: string
18
+ }
19
+
20
+ export type Config = {
21
+ profiles: Record<string, CliProfile>
22
+ currentProfile: string
23
+ }
24
+
25
+ export function emptyConfig(): Config {
26
+ return {
27
+ profiles: {},
28
+ currentProfile: "",
29
+ }
30
+ }
31
+
32
+ export function configDir() {
33
+ return process.env.XDG_CONFIG_HOME
34
+ ? join(process.env.XDG_CONFIG_HOME, "onyx")
35
+ : join(homedir(), ".config", "onyx")
36
+ }
37
+
38
+ export function configPath() {
39
+ return join(configDir(), CONFIG_FILE)
40
+ }
41
+
42
+ export async function readConfig(): Promise<Config> {
43
+ try {
44
+ const parsed = JSON.parse(
45
+ await readFile(configPath(), "utf8")
46
+ ) as Partial<Config>
47
+
48
+ return {
49
+ profiles: parsed.profiles ?? {},
50
+ currentProfile: parsed.currentProfile ?? "",
51
+ }
52
+ } catch {
53
+ return emptyConfig()
54
+ }
55
+ }
56
+
57
+ export async function writeConfig(config: Config) {
58
+ await mkdir(configDir(), { recursive: true })
59
+ await writeFile(configPath(), `${JSON.stringify(config, null, 2)}\n`, {
60
+ encoding: "utf8",
61
+ mode: 0o600,
62
+ })
63
+ }
64
+
65
+ export function normalizeProfileName(value: string) {
66
+ return value
67
+ .toLowerCase()
68
+ .trim()
69
+ .replace(/[^a-z0-9]+/g, "-")
70
+ .replace(/^-+|-+$/g, "")
71
+ }
72
+
73
+ function profileNameFromArgs(args: Args | undefined, config: Config) {
74
+ return args?.options.profile ?? config.currentProfile
75
+ }
76
+
77
+ async function selectedProfileEntry(args?: Args): Promise<{
78
+ name: string
79
+ profile: CliProfile
80
+ }> {
81
+ const config = await readConfig()
82
+ const profileName = profileNameFromArgs(args, config)
83
+
84
+ if (!profileName) {
85
+ throw new Error(
86
+ "No Onyx CLI profile selected. Run `onyx login` or set ONYX_API_KEY."
87
+ )
88
+ }
89
+
90
+ const profile = config.profiles[profileName]
91
+ if (!profile) {
92
+ throw new Error(
93
+ `Unknown Onyx CLI profile "${profileName}". Run \`onyx profile list\`.`
94
+ )
95
+ }
96
+
97
+ return { name: profileName, profile }
98
+ }
99
+
100
+ export async function selectedProfile(args?: Args): Promise<CliProfile> {
101
+ return (await selectedProfileEntry(args)).profile
102
+ }
103
+
104
+ export async function apiBaseUrl(
105
+ args?: Args,
106
+ options?: { allowDefault?: boolean }
107
+ ) {
108
+ if (args?.options["api-url"]) return args.options["api-url"]
109
+ if (process.env.ONYX_API_URL) return process.env.ONYX_API_URL
110
+
111
+ const config = await readConfig()
112
+ const profileName = profileNameFromArgs(args, config)
113
+ const profile = profileName ? config.profiles[profileName] : undefined
114
+
115
+ if (profile) return profile.apiUrl
116
+ if (process.env.ONYX_API_KEY) return DEFAULT_API_URL
117
+ if (options?.allowDefault) return DEFAULT_API_URL
118
+
119
+ if (profileName) {
120
+ throw new Error(
121
+ `Unknown Onyx CLI profile "${profileName}". Run \`onyx profile list\`.`
122
+ )
123
+ }
124
+
125
+ throw new Error(
126
+ "No Onyx CLI profile selected. Run `onyx login` or set ONYX_API_URL."
127
+ )
128
+ }
129
+
130
+ export async function apiKey(args?: Args) {
131
+ if (process.env.ONYX_API_KEY) return process.env.ONYX_API_KEY
132
+
133
+ const { name, profile } = await selectedProfileEntry(args)
134
+ if (profile.apiKeyEnv) {
135
+ const value = process.env[profile.apiKeyEnv]
136
+ if (value?.trim()) return value
137
+
138
+ throw new Error(
139
+ `Profile "${name}" expects API key in ${profile.apiKeyEnv}, but that environment variable is not set or empty.`
140
+ )
141
+ }
142
+
143
+ if (profile.apiKey) return profile.apiKey
144
+
145
+ throw new Error(
146
+ `Profile "${name}" has no API key. Run \`onyx login --refresh\` or \`onyx profile set-api-key-env ${name} <ENV_VAR>\`.`
147
+ )
148
+ }
@@ -0,0 +1,97 @@
1
+ import { readFile, rename, stat, writeFile } from "node:fs/promises"
2
+ import { join } from "node:path"
3
+
4
+ import {
5
+ localResearchEventSchema,
6
+ type LocalResearchEvent,
7
+ type LocalResearchEventType,
8
+ } from "../protocol"
9
+
10
+ import { onyxStateDir } from "./outbox"
11
+
12
+ const TRUNCATE_BYTES = 256 * 1024
13
+ const TRUNCATE_KEEP = 500
14
+
15
+ export async function eventsPath(root: string) {
16
+ return join(await onyxStateDir(root), "events.jsonl")
17
+ }
18
+
19
+ /**
20
+ * Appends a local activity event for `onyx listen`. Strictly best-effort:
21
+ * never throws, so a broken feed can never fail a command.
22
+ */
23
+ export async function emitEvent(
24
+ root: string,
25
+ event: {
26
+ type: LocalResearchEventType
27
+ branchName?: string
28
+ commitSha?: string
29
+ message?: string
30
+ }
31
+ ): Promise<void> {
32
+ try {
33
+ const validated = localResearchEventSchema.parse({
34
+ schemaVersion: 1,
35
+ ts: new Date().toISOString(),
36
+ ...event,
37
+ })
38
+ const path = await eventsPath(root)
39
+ await writeFile(path, `${JSON.stringify(validated)}\n`, {
40
+ encoding: "utf8",
41
+ flag: "a",
42
+ })
43
+ await truncateEvents(root)
44
+ } catch {
45
+ // Activity feed is observational only.
46
+ }
47
+ }
48
+
49
+ /**
50
+ * Reads activity events, skipping corrupt lines. `tail` limits parsing to the
51
+ * last N lines so the listener stays cheap as the feed grows.
52
+ */
53
+ export async function readEvents(
54
+ root: string,
55
+ options: { tail?: number } = {}
56
+ ): Promise<LocalResearchEvent[]> {
57
+ let text = ""
58
+ try {
59
+ text = await readFile(await eventsPath(root), "utf8")
60
+ } catch {
61
+ return []
62
+ }
63
+
64
+ let lines = text.split("\n")
65
+ if (options.tail !== undefined) lines = lines.slice(-options.tail - 1)
66
+
67
+ const events: LocalResearchEvent[] = []
68
+ for (const line of lines) {
69
+ const trimmed = line.trim()
70
+ if (!trimmed) continue
71
+ try {
72
+ const result = localResearchEventSchema.safeParse(JSON.parse(trimmed))
73
+ if (result.success) events.push(result.data)
74
+ } catch {
75
+ // skip corrupt line
76
+ }
77
+ }
78
+ return events
79
+ }
80
+
81
+ /** Atomically trims the feed to the most recent events once it grows large. */
82
+ export async function truncateEvents(root: string, keep = TRUNCATE_KEEP) {
83
+ const path = await eventsPath(root)
84
+ try {
85
+ const { size } = await stat(path)
86
+ if (size <= TRUNCATE_BYTES) return
87
+ const lines = (await readFile(path, "utf8"))
88
+ .split("\n")
89
+ .filter((line) => line.trim())
90
+ if (lines.length <= keep) return
91
+ const tmp = `${path}.tmp`
92
+ await writeFile(tmp, `${lines.slice(-keep).join("\n")}\n`, "utf8")
93
+ await rename(tmp, path)
94
+ } catch {
95
+ // best-effort
96
+ }
97
+ }