From 01841b094179bee99aacf2f3d81fdbdc9fa520bd Mon Sep 17 00:00:00 2001 From: code-crusher Date: Fri, 31 Jul 2026 20:53:42 +0530 Subject: [PATCH] release: v6.7.1 --- CHANGELOG.md | 9 +- package.json | 3 +- src/api/metrics.ts | 245 ++++++++++++++++++++++++++++++++++++++++++- src/core/agent.ts | 76 ++++++++++---- src/core/sessions.ts | 2 + test/metrics.test.ts | 49 +++++++++ 6 files changed, 358 insertions(+), 26 deletions(-) create mode 100644 test/metrics.test.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index fd2fcb1..e18ef7c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,12 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [6.7.1] - 2026-07-31 + +### Added + +- **Organization usage metrics.** OrbCode now reports metadata-only user-message events, model/version-aware accepted agent line counts, and newly observed Git commit line totals for AI-share, active-user, leaderboard, conversation, and client-version analytics. Git remotes are credential-sanitized, and prompt/file contents are never included. + ## [6.7.0] - 2026-07-29 ### Added @@ -856,7 +862,8 @@ non-interactive mode. - Cross-platform shell detection and path handling in `execute_command` (Windows vs POSIX, `cmd` vs `bash`, etc.). -[Unreleased]: https://github.com/MatterAIOrg/OrbCode/compare/v6.7.0...HEAD +[Unreleased]: https://github.com/MatterAIOrg/OrbCode/compare/v6.7.1...HEAD +[6.7.1]: https://github.com/MatterAIOrg/OrbCode/compare/v6.7.0...v6.7.1 [6.7.0]: https://github.com/MatterAIOrg/OrbCode/compare/v6.6.8...v6.7.0 [6.6.7]: https://github.com/MatterAIOrg/OrbCode/compare/v0.6.0...v6.6.7 [0.6.0]: https://github.com/MatterAIOrg/OrbCode/compare/v0.5.10...v0.6.0 diff --git a/package.json b/package.json index 002a080..a0ca24d 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@matterailab/orbcode", - "version": "6.7.0", + "version": "6.7.1", "description": "OrbCode CLI — agentic coding in your terminal, powered by Axon models by MatterAI", "type": "module", "bin": { @@ -36,6 +36,7 @@ "test:attachments": "node --import tsx --test test/attachments.test.ts", "test:mcp": "node --import tsx --test test/mcp-client.test.ts", "test:plugins": "node --import tsx --test test/plugins.test.ts", + "test:metrics": "node --import tsx --test test/metrics.test.ts", "test:search": "node --import tsx --test test/search-files.test.ts", "test:ui": "bun test test/ui-viewport.test.tsx", "prepublishOnly": "npm run typecheck && npm run build" diff --git a/src/api/metrics.ts b/src/api/metrics.ts index 201741f..745a3de 100644 --- a/src/api/metrics.ts +++ b/src/api/metrics.ts @@ -1,7 +1,16 @@ +import { execFileSync } from "node:child_process" +import { randomUUID } from "node:crypto" +import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs" import * as path from "node:path" import { getUrlFromToken } from "../auth/auth.js" -import { X_AXON_REPO } from "./headers.js" +import { VERSION } from "../branding.js" +import { getConfigDir } from "../config/settings.js" +import { + DEFAULT_HEADERS, + getClientMetadataHeaders, + X_AXON_REPO, +} from "./headers.js" /** File-extension → language code mapping (ported from the extension). */ const LANGUAGE_MAP: Record = { @@ -56,6 +65,222 @@ export interface ReportLineMetricsOptions { linesAdded: number linesDeleted: number linesUpdated?: number + model?: string +} + +export interface UsageEvent { + eventId?: string + eventType: "user_message" | "committed_code" | "client_heartbeat" + taskId?: string + model?: string + repo: string + linesAdded?: number + linesModified?: number + linesDeleted?: number + commitHash?: string + timestamp?: string +} + +export interface GitCommitMetrics { + hash: string + linesAdded: number + linesDeleted: number + timestamp: string + authorEmail: string +} + +export interface ObservedGitCommits { + head?: string + commits: GitCommitMetrics[] +} + +/** Remove credentials and normalize common SSH remotes before telemetry. */ +export function normalizeGitRemote(remote: string): string { + const value = remote.trim() + const scpMatch = value.match(/^(?:[^@]+@)?([^:]+):(.+)$/) + if (scpMatch && !value.includes("://")) { + return `https://${scpMatch[1]}/${scpMatch[2]}` + } + + try { + const url = new URL(value) + url.username = "" + url.password = "" + url.search = "" + url.hash = "" + return url.toString().replace(/\/$/, "") + } catch { + return "" + } +} + +export function detectGitRepo(cwd: string): string { + try { + const remote = execFileSync("git", ["config", "--get", "remote.origin.url"], { + cwd, + stdio: ["ignore", "pipe", "ignore"], + }) + .toString() + .trim() + const normalized = normalizeGitRemote(remote) + if (normalized) return normalized + } catch { + // not a git repo or no remote + } + return path.basename(cwd) +} + +export function getGitHead(cwd: string): string | undefined { + try { + return execFileSync("git", ["rev-parse", "HEAD"], { + cwd, + stdio: ["ignore", "pipe", "ignore"], + }) + .toString() + .trim() + } catch { + return undefined + } +} + +const usageHeadsPath = (): string => path.join(getConfigDir(), "usage-git-heads.json") + +const usageRepoKey = (cwd: string): string => `${detectGitRepo(cwd)}|${path.resolve(cwd)}` + +export function getPersistedGitHead(cwd: string): string | undefined { + try { + const file = usageHeadsPath() + if (!existsSync(file)) return undefined + const heads = JSON.parse(readFileSync(file, "utf8")) as Record + return heads[usageRepoKey(cwd)] + } catch { + return undefined + } +} + +export function persistGitHead(cwd: string, head: string | undefined): void { + if (!head) return + try { + const file = usageHeadsPath() + mkdirSync(path.dirname(file), { recursive: true }) + let heads: Record = {} + if (existsSync(file)) { + try { + heads = JSON.parse(readFileSync(file, "utf8")) as Record + } catch { + // Replace an unreadable tracker file with a fresh map. + } + } + heads[usageRepoKey(cwd)] = head + writeFileSync(file, JSON.stringify(heads), { mode: 0o600 }) + } catch { + // Local baselines are best-effort, like the network event itself. + } +} + +/** + * Return commits created since the last observed HEAD. Branch switches and + * rewritten/non-descendant histories become a new baseline instead of being + * misreported as newly authored code. + */ +export function observeGitCommits(cwd: string, previousHead?: string): ObservedGitCommits { + const head = getGitHead(cwd) + if (!head || !previousHead || head === previousHead) return { head, commits: [] } + + try { + execFileSync("git", ["merge-base", "--is-ancestor", previousHead, head], { + cwd, + stdio: "ignore", + }) + } catch { + return { head, commits: [] } + } + + try { + let configuredAuthorEmail = "" + try { + configuredAuthorEmail = execFileSync("git", ["config", "--get", "user.email"], { + cwd, + stdio: ["ignore", "pipe", "ignore"], + }) + .toString() + .trim() + .toLowerCase() + } catch { + // A repository can still have commits when identity comes from env vars. + } + const hashes = execFileSync("git", ["rev-list", "--reverse", `${previousHead}..${head}`], { + cwd, + stdio: ["ignore", "pipe", "ignore"], + }) + .toString() + .trim() + .split("\n") + .filter(Boolean) + + const commits = hashes.map((hash) => { + const authorEmail = execFileSync("git", ["show", "-s", "--format=%ae", hash], { + cwd, + stdio: ["ignore", "pipe", "ignore"], + }) + .toString() + .trim() + const numstat = execFileSync("git", ["show", "--numstat", "--format=", "--no-renames", hash], { + cwd, + stdio: ["ignore", "pipe", "ignore"], + }).toString() + let linesAdded = 0 + let linesDeleted = 0 + for (const line of numstat.split("\n")) { + const [added, deleted] = line.split("\t") + if (/^\d+$/.test(added)) linesAdded += Number(added) + if (/^\d+$/.test(deleted)) linesDeleted += Number(deleted) + } + const timestamp = execFileSync("git", ["show", "-s", "--format=%cI", hash], { + cwd, + stdio: ["ignore", "pipe", "ignore"], + }) + .toString() + .trim() + return { hash, linesAdded, linesDeleted, timestamp, authorEmail } + }).filter( + (commit) => + !configuredAuthorEmail || commit.authorEmail.toLowerCase() === configuredAuthorEmail, + ) + + return { head, commits } + } catch { + return { head, commits: [] } + } +} + +/** Report a non-content usage event. User prompt text is never transmitted. */ +export async function reportUsageEvent(token: string, event: UsageEvent): Promise { + if (!token) return + const url = getUrlFromToken("https://api.matterai.so/axoncode/usage/events", token) + + try { + await fetch(url, { + method: "POST", + headers: { + ...DEFAULT_HEADERS, + ...getClientMetadataHeaders(0), + "Content-Type": "application/json", + Authorization: `Bearer ${token}`, + [X_AXON_REPO]: event.repo, + }, + body: JSON.stringify({ + ...event, + eventId: event.eventId || randomUUID(), + client: "orbcode", + clientVersion: VERSION, + ideName: "CLI", + }), + signal: AbortSignal.timeout(10000), + }) + } catch { + // network/timeout errors: best-effort, swallow + } } /** @@ -65,7 +290,7 @@ export interface ReportLineMetricsOptions { * only after the edit has been written to disk. */ export async function reportLineMetrics(options: ReportLineMetricsOptions): Promise { - const { taskId, token, repo, language, linesAdded, linesDeleted } = options + const { taskId, token, repo, language, linesAdded, linesDeleted, model } = options const linesUpdated = options.linesUpdated ?? 0 if (!token) return if (linesAdded === 0 && linesDeleted === 0 && linesUpdated === 0) return @@ -79,14 +304,26 @@ export async function reportLineMetrics(options: ReportLineMetricsOptions): Prom await fetch(url, { method: "POST", headers: { + ...DEFAULT_HEADERS, + ...getClientMetadataHeaders(0), "Content-Type": "application/json", Authorization: `Bearer ${token}`, [X_AXON_REPO]: repo, }, - body: JSON.stringify({ language, linesAdded, linesUpdated, linesDeleted }), + body: JSON.stringify({ + eventId: randomUUID(), + language, + linesAdded, + linesUpdated, + linesDeleted, + model, + client: "orbcode", + clientVersion: VERSION, + ideName: "CLI", + }), signal: AbortSignal.timeout(10000), }) } catch { // network/timeout errors: best-effort, swallow } -} \ No newline at end of file +} diff --git a/src/core/agent.ts b/src/core/agent.ts index e17d832..625d1c8 100644 --- a/src/core/agent.ts +++ b/src/core/agent.ts @@ -1,5 +1,4 @@ import { execSync } from "node:child_process" -import * as path from "node:path" import { randomUUID } from "node:crypto" import type OpenAI from "openai" @@ -37,7 +36,17 @@ import { loadMemoryFiles } from "../memory/loader.js" import { loadSkills } from "../skills/loader.js" import { renderLinkedReposSection } from "../config/links.js" import { unifiedDiff } from "../utils/diff.js" -import { countDiffLines, getLanguageFromPath, reportLineMetrics } from "../api/metrics.js" +import { + countDiffLines, + detectGitRepo, + getGitHead, + getPersistedGitHead, + getLanguageFromPath, + observeGitCommits, + persistGitHead, + reportLineMetrics, + reportUsageEvent, +} from "../api/metrics.js" const MAX_STEPS_PER_TURN = 50 const RESULT_PREVIEW_LINES = 6 @@ -133,21 +142,6 @@ interface PendingToolCall { arguments: string } -function detectRepo(cwd: string): string { - try { - const remote = execSync("git config --get remote.origin.url", { - cwd, - stdio: ["ignore", "pipe", "ignore"], - }) - .toString() - .trim() - if (remote) return remote - } catch { - // not a git repo or no remote - } - return path.basename(cwd) -} - function getGitSummary(cwd: string): string { try { const branch = execSync("git rev-parse --abbrev-ref HEAD", { cwd, stdio: ["ignore", "pipe", "ignore"] }) @@ -349,6 +343,7 @@ export class Agent { private contextTokens = 0 private title = "" private createdAt = new Date().toISOString() + private lastGitHead?: string private readonly hooks: HookRunner /** MCP server manager (may be undefined when MCP is disabled). */ private mcp?: McpManager @@ -378,6 +373,11 @@ export class Agent { }, } this.taskId = options.resume?.id ?? randomUUID() + this.lastGitHead = + options.resume?.lastGitHead ?? + getPersistedGitHead(options.cwd) ?? + getGitHead(options.cwd) + persistGitHead(options.cwd, this.lastGitHead) this.hooks = new HookRunner({ cwd: options.cwd, sessionId: this.taskId, @@ -412,7 +412,7 @@ export class Agent { modelId: options.modelId, taskId: this.taskId, organizationId: options.organizationId, - repo: detectRepo(options.cwd), + repo: detectGitRepo(options.cwd), baseUrl: options.baseUrl, }) } @@ -424,7 +424,7 @@ export class Agent { modelId, taskId: this.taskId, organizationId: this.options.organizationId, - repo: detectRepo(this.options.cwd), + repo: detectGitRepo(this.options.cwd), baseUrl: this.options.baseUrl, }) } @@ -552,6 +552,8 @@ export class Agent { this.pendingStartContext = "" this.sessionStarted = false this.stopHookActive = false + this.lastGitHead = getGitHead(this.options.cwd) + persistGitHead(this.options.cwd, this.lastGitHead) } /** Write the current conversation to the sessions directory. */ @@ -568,6 +570,7 @@ export class Agent { totalCost: this.totalCost, contextTokens: this.contextTokens, todos: this.todos, + lastGitHead: this.lastGitHead, messages: this.messages, transcript: this.transcript, }) @@ -639,6 +642,13 @@ User time zone: ${timeZone}, UTC${timeZoneOffsetStr}` async runTurn(userText: string, attachments: Attachment[] = []): Promise { const { onEvent } = this.options.callbacks this.abortController = new AbortController() + this.observeCommittedCode() + this.trackBackground(reportUsageEvent(this.options.token, { + eventType: "user_message", + taskId: this.taskId, + model: this.options.modelId, + repo: detectGitRepo(this.options.cwd), + })) this.transcript.push({ kind: "user", text: userText, @@ -815,6 +825,29 @@ User time zone: ${timeZone}, UTC${timeZoneOffsetStr}` p.finally(() => this.pendingBackground.delete(p)) } + /** Detect descendant Git commits made while this session is alive. */ + private observeCommittedCode(): void { + const observed = observeGitCommits(this.options.cwd, this.lastGitHead) + this.lastGitHead = observed.head + persistGitHead(this.options.cwd, this.lastGitHead) + if (observed.commits.length === 0) return + + const repo = detectGitRepo(this.options.cwd) + for (const commit of observed.commits) { + this.trackBackground(reportUsageEvent(this.options.token, { + eventId: `commit:${commit.hash}:${repo}`, + eventType: "committed_code", + taskId: this.taskId, + model: this.options.modelId, + repo, + linesAdded: commit.linesAdded, + linesDeleted: commit.linesDeleted, + commitHash: commit.hash, + timestamp: commit.timestamp, + })) + } + } + /** Wait (up to `timeoutMs`) for in-flight background hooks to settle. */ private async awaitBackground(timeoutMs: number): Promise { if (this.pendingBackground.size === 0) return @@ -828,6 +861,7 @@ User time zone: ${timeZone}, UTC${timeZoneOffsetStr}` /** Fire SessionEnd hooks. Best-effort; never blocks shutdown. */ async endSession(reason: string): Promise { + this.observeCommittedCode() // Let in-flight Notification hooks settle before the final SessionEnd. await this.awaitBackground(3000) if (this.hooks.hasHooks("SessionEnd")) { @@ -1251,6 +1285,7 @@ User time zone: ${timeZone}, UTC${timeZoneOffsetStr}` // potentially long synchronous tool call blocks the event loop. await new Promise(resolve => setImmediate(resolve)) const result = await executeTool(toolCall.name, args, this.toolContext(), this.mcp) + this.observeCommittedCode() // PostToolUse can feed extra context (or a block reason) back to the // model. PreToolUse additionalContext is delivered here too. @@ -1284,10 +1319,11 @@ User time zone: ${timeZone}, UTC${timeZoneOffsetStr}` this.trackBackground(reportLineMetrics({ taskId: this.taskId, token: this.options.token, - repo: detectRepo(this.options.cwd), + repo: detectGitRepo(this.options.cwd), language: getLanguageFromPath(filePath), linesAdded, linesDeleted, + model: this.options.modelId, })) } } diff --git a/src/core/sessions.ts b/src/core/sessions.ts index 912f878..90f7649 100644 --- a/src/core/sessions.ts +++ b/src/core/sessions.ts @@ -37,6 +37,8 @@ export interface SessionData { */ contextTokens: number todos: string + /** Last observed Git commit, used to report commits across resumed sessions. */ + lastGitHead?: string messages: OpenAI.Chat.ChatCompletionMessageParam[] /** Exact visible TUI history. Optional for sessions written before v0.4.2. */ transcript?: SessionTranscriptEntry[] diff --git a/test/metrics.test.ts b/test/metrics.test.ts new file mode 100644 index 0000000..961345e --- /dev/null +++ b/test/metrics.test.ts @@ -0,0 +1,49 @@ +import assert from "node:assert/strict" +import { execFileSync } from "node:child_process" +import { mkdtempSync, rmSync, writeFileSync } from "node:fs" +import { tmpdir } from "node:os" +import path from "node:path" +import test from "node:test" + +import { + getGitHead, + normalizeGitRemote, + observeGitCommits, +} from "../src/api/metrics.js" + +test("normalizes Git remotes without leaking credentials", () => { + assert.equal( + normalizeGitRemote("https://user:secret@github.com/acme/widget.git?token=bad#fragment"), + "https://github.com/acme/widget.git", + ) + assert.equal( + normalizeGitRemote("git@github.com:acme/widget.git"), + "https://github.com/acme/widget.git", + ) +}) + +test("observes descendant commits and their numstat totals", () => { + const repo = mkdtempSync(path.join(tmpdir(), "orbcode-metrics-")) + try { + execFileSync("git", ["init", "-q"], { cwd: repo }) + execFileSync("git", ["config", "user.name", "Metrics Test"], { cwd: repo }) + execFileSync("git", ["config", "user.email", "metrics@example.com"], { cwd: repo }) + writeFileSync(path.join(repo, "sample.ts"), "one\n") + execFileSync("git", ["add", "sample.ts"], { cwd: repo }) + execFileSync("git", ["commit", "-q", "-m", "initial"], { cwd: repo }) + + const baseline = getGitHead(repo) + assert.ok(baseline) + writeFileSync(path.join(repo, "sample.ts"), "one\ntwo\nthree\n") + execFileSync("git", ["add", "sample.ts"], { cwd: repo }) + execFileSync("git", ["commit", "-q", "-m", "add lines"], { cwd: repo }) + + const observed = observeGitCommits(repo, baseline) + assert.equal(observed.commits.length, 1) + assert.equal(observed.commits[0].linesAdded, 2) + assert.equal(observed.commits[0].linesDeleted, 0) + assert.match(observed.commits[0].timestamp, /^\d{4}-\d{2}-\d{2}T/) + } finally { + rmSync(repo, { recursive: true, force: true }) + } +})