Baseline: omarchy.agents as shipped
Panel.qml, Main.qml, Agent.qml and the provider assets exactly as they ship in omarchy-dev 4.0.0.r1744.gf002044 (shell/plugins/agents/), alongside the manifest `omarchy plugin clone` generates. Kept on its own branch so `git diff stock..master` shows precisely what this fork changes.
This commit is contained in:
@@ -0,0 +1,748 @@
|
||||
import QtQuick
|
||||
import Quickshell
|
||||
import Quickshell.Io
|
||||
|
||||
// The display side of agent usage. All extraction lives behind
|
||||
// omarchy-agent-usage-update, which writes one JSON record per agent into
|
||||
// the usage directory; this file only discovers those records, watches them
|
||||
// for changes, and optionally merges snapshots synced from other machines.
|
||||
Item {
|
||||
id: root
|
||||
visible: false
|
||||
|
||||
property var settings: ({})
|
||||
|
||||
readonly property string home: Quickshell.env("HOME") || ""
|
||||
readonly property string usageDir: (Quickshell.env("XDG_STATE_HOME") || home + "/.local/state") + "/omarchy/agents/usage"
|
||||
|
||||
// ------------------------------------------------------------- discovery
|
||||
|
||||
property var agentIds: []
|
||||
property var agents: []
|
||||
property int dataRevision: 0
|
||||
|
||||
Process {
|
||||
id: listProcess
|
||||
running: false
|
||||
command: ["find", root.usageDir, "-maxdepth", "1", "-name", "*.json", "-printf", "%f\n"]
|
||||
|
||||
stdout: StdioCollector {
|
||||
waitForEnd: true
|
||||
onStreamFinished: root.applyAgentListing(text)
|
||||
}
|
||||
}
|
||||
|
||||
function rescanAgents() {
|
||||
if (!listProcess.running) listProcess.running = true
|
||||
}
|
||||
|
||||
function applyAgentListing(output) {
|
||||
var ids = []
|
||||
var lines = String(output || "").split("\n")
|
||||
for (var i = 0; i < lines.length; i++) {
|
||||
var name = lines[i].trim()
|
||||
if (name.slice(-5) === ".json") ids.push(name.slice(0, -5))
|
||||
}
|
||||
ids.sort()
|
||||
// Same list, same objects: reassigning the model would tear down every
|
||||
// FileView just to build identical ones.
|
||||
if (JSON.stringify(ids) !== JSON.stringify(agentIds)) agentIds = ids
|
||||
}
|
||||
|
||||
Instantiator {
|
||||
id: agentInstantiator
|
||||
model: root.agentIds
|
||||
|
||||
delegate: Agent {
|
||||
required property var modelData
|
||||
agentId: modelData
|
||||
path: root.usageDir + "/" + modelData + ".json"
|
||||
onRecordChanged: root.recordsChanged()
|
||||
}
|
||||
|
||||
onObjectAdded: (index, object) => root.rebuildAgents()
|
||||
onObjectRemoved: (index, object) => root.rebuildAgents()
|
||||
}
|
||||
|
||||
function rebuildAgents() {
|
||||
var result = []
|
||||
for (var i = 0; i < agentInstantiator.count; i++) {
|
||||
var agent = agentInstantiator.objectAt(i)
|
||||
if (agent) result.push(agent)
|
||||
}
|
||||
agents = result
|
||||
recordsChanged()
|
||||
}
|
||||
|
||||
function recordsChanged() {
|
||||
dataRevision++
|
||||
scheduleLimitsRetry()
|
||||
scheduleSync()
|
||||
}
|
||||
|
||||
// A collector that could not reach its limits endpoint at all — typically
|
||||
// the seconds after login before the network is up — writes retryAdvised
|
||||
// into its record. Honor it with one sooner try instead of waiting out the
|
||||
// full refresh interval; a run that reaches the endpoint clears the flag.
|
||||
// Only the advising agents rerun, so an outage at one provider does not
|
||||
// put every other collector on a 30-second treadmill.
|
||||
property var retryAgentIds: []
|
||||
|
||||
Timer {
|
||||
id: limitsRetry
|
||||
interval: 30000
|
||||
repeat: false
|
||||
onTriggered: root.runUpdate("limits", root.retryAgentIds)
|
||||
}
|
||||
|
||||
function scheduleLimitsRetry() {
|
||||
var advising = []
|
||||
for (var i = 0; i < agents.length; i++) {
|
||||
var record = agents[i] ? agents[i].record : null
|
||||
if (record && record.retryAdvised === true && providerEnabled(String(record.id || "")))
|
||||
advising.push(String(record.id))
|
||||
}
|
||||
retryAgentIds = advising
|
||||
if (advising.length > 0) limitsRetry.restart()
|
||||
else limitsRetry.stop()
|
||||
}
|
||||
|
||||
Component.onCompleted: {
|
||||
rescanAgents()
|
||||
if (syncConfigured()) scheduleSync()
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------- refresh
|
||||
|
||||
property int refreshIntervalSec: Math.max(30, Number(setting("refreshIntervalSec", 900)))
|
||||
property string pendingUpdateKind: ""
|
||||
|
||||
Timer {
|
||||
interval: root.refreshIntervalSec * 1000
|
||||
running: true
|
||||
repeat: true
|
||||
triggeredOnStart: true
|
||||
onTriggered: root.runUpdate("normal")
|
||||
}
|
||||
|
||||
Process {
|
||||
id: updateProcess
|
||||
running: false
|
||||
onExited: {
|
||||
root.rescanAgents()
|
||||
if (root.pendingUpdateKind !== "") {
|
||||
var kind = root.pendingUpdateKind
|
||||
root.pendingUpdateKind = ""
|
||||
root.runUpdate(kind)
|
||||
}
|
||||
}
|
||||
|
||||
stderr: StdioCollector {
|
||||
waitForEnd: true
|
||||
onStreamFinished: if (text.trim() !== "") console.warn("agents", text.trim())
|
||||
}
|
||||
}
|
||||
|
||||
function updateCommand(kind, agentIds) {
|
||||
var command = ["omarchy-agent-usage-update"]
|
||||
if (kind === "force") command.push("--force")
|
||||
if (kind === "limits") command.push("--limits-only")
|
||||
var providers = settings && settings.providers ? settings.providers : {}
|
||||
for (var id in providers) {
|
||||
if (providers[id] && providers[id].enabled === false) command.push("--except", id)
|
||||
}
|
||||
if (agentIds) {
|
||||
for (var i = 0; i < agentIds.length; i++) command.push(agentIds[i])
|
||||
}
|
||||
return command
|
||||
}
|
||||
|
||||
function runUpdate(kind, agentIds) {
|
||||
if (updateProcess.running) {
|
||||
// Collapse queued requests to one full rerun; a forced refresh outranks
|
||||
// the cheaper kinds it might have been queued behind.
|
||||
if (kind === "force" || root.pendingUpdateKind === "") root.pendingUpdateKind = kind
|
||||
return
|
||||
}
|
||||
updateProcess.command = updateCommand(kind, agentIds)
|
||||
updateProcess.running = true
|
||||
}
|
||||
|
||||
function refresh() { refreshAll(true) }
|
||||
function refreshAll(force) { runUpdate(force === true ? "force" : "normal") }
|
||||
|
||||
// Opening the panel wants the numbers that go stale on the wire, not
|
||||
// another walk over every transcript on disk — the collectors reuse their
|
||||
// recent scans in this mode.
|
||||
function refreshLimits() { runUpdate("limits") }
|
||||
|
||||
// ------------------------------------------------------------- providers
|
||||
|
||||
// An agent earns a place in the bar and the panel by being switched on in
|
||||
// settings and having actually produced numbers — locally or on a synced
|
||||
// device. With nothing to show, the whole module collapses out of the bar
|
||||
// rather than sitting there dimmed.
|
||||
property var enabledProviders: {
|
||||
var rev = dataRevision
|
||||
var syncRev = syncRevision
|
||||
var result = []
|
||||
var localIds = {}
|
||||
for (var i = 0; i < agents.length; i++) {
|
||||
var record = agents[i] ? agents[i].record : null
|
||||
if (!record || !record.id) continue
|
||||
var id = String(record.id)
|
||||
localIds[id] = true
|
||||
if (!providerEnabled(id)) continue
|
||||
var display = displayProvider(record)
|
||||
if (providerHasData(display)) result.push(display)
|
||||
}
|
||||
// An agent that only ever ran on another machine has no local record, but
|
||||
// its synced numbers still deserve a tab. Rate limits stay blank — they
|
||||
// are per-account and never travel.
|
||||
var syncedProviders = syncConfigured() && aggregateData && aggregateData.providers ? aggregateData.providers : {}
|
||||
for (var syncedId in syncedProviders) {
|
||||
if (localIds[syncedId] || !providerEnabled(syncedId)) continue
|
||||
var stats = syncedProviders[syncedId] || {}
|
||||
var syncedDisplay = displayProvider({ id: syncedId, name: stats.providerName || syncedId })
|
||||
if (providerHasData(syncedDisplay)) result.push(syncedDisplay)
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
function providerEnabled(id) {
|
||||
if (!settings || !settings.providers || !settings.providers[id]) return true
|
||||
return settings.providers[id].enabled !== false
|
||||
}
|
||||
|
||||
// All-time keeps a quiet day from hiding an agent; today's counts admit a
|
||||
// machine whose only source is history.jsonl, which knows nothing older.
|
||||
function providerHasData(p) {
|
||||
return numberValue(p.totalPrompts) > 0 || numberValue(p.totalSessions) > 0
|
||||
|| numberValue(p.activeDays) > 0 || numberValue(p.todayPrompts) > 0
|
||||
|| numberValue(p.todaySessions) > 0 || (p.limits && p.limits.length > 0)
|
||||
|| !!p.balance
|
||||
}
|
||||
|
||||
// A prepaid agent's credit ledger. Like rate limits, the balance is
|
||||
// per-account and never merged across devices.
|
||||
function balanceValue(raw) {
|
||||
if (!raw || typeof raw !== "object") return null
|
||||
var remaining = Number(raw.remaining)
|
||||
var funded = Number(raw.funded)
|
||||
if (!isFinite(remaining) || remaining < 0) return null
|
||||
return {
|
||||
remaining: remaining,
|
||||
funded: isFinite(funded) && funded > 0 ? funded : 0,
|
||||
spent: Math.max(0, Number(raw.spent) || 0),
|
||||
currency: String(raw.currency || "USD"),
|
||||
estimated: raw.estimated === true
|
||||
}
|
||||
}
|
||||
|
||||
function displayProvider(record) {
|
||||
var stats = syncedStatsFor(String(record.id))
|
||||
var synced = !!stats
|
||||
var deviceCount = synced ? Number(stats.deviceCount || aggregateData.deviceCount || 0) : 0
|
||||
|
||||
return {
|
||||
providerId: String(record.id),
|
||||
providerName: String(record.name || record.id),
|
||||
ready: record.ready === true || synced,
|
||||
usageStatusText: String(record.usageStatusText || ""),
|
||||
authHelpText: String(record.authHelpText || ""),
|
||||
|
||||
// Rate limits and balances stay per-account and are never merged
|
||||
// across devices.
|
||||
limits: Array.isArray(record.limits) ? record.limits : [],
|
||||
tierLabel: String(record.tierLabel || ""),
|
||||
balance: balanceValue(record.balance),
|
||||
|
||||
todayPrompts: synced ? numberValue(stats.todayPrompts) : numberValue(record.todayPrompts),
|
||||
todaySessions: synced ? numberValue(stats.todaySessions) : numberValue(record.todaySessions),
|
||||
todayTotalTokens: synced ? numberValue(stats.todayTotalTokens) : numberValue(record.todayTotalTokens),
|
||||
todayTokensByModel: synced ? (stats.todayTokensByModel || ({})) : (record.todayTokensByModel || ({})),
|
||||
recentDays: synced ? (stats.recentDays || []) : (record.recentDays || []),
|
||||
totalPrompts: synced ? numberValue(stats.totalPrompts) : numberValue(record.totalPrompts),
|
||||
totalSessions: synced ? numberValue(stats.totalSessions) : numberValue(record.totalSessions),
|
||||
activeDays: synced ? numberValue(stats.activeDays) : numberValue(record.activeDays),
|
||||
modelUsage: synced ? (stats.modelUsage || ({})) : (record.modelUsage || ({})),
|
||||
hasLocalStats: synced ? (stats.hasLocalStats !== false) : (record.hasLocalStats !== false),
|
||||
hasPromptStats: synced ? (stats.hasPromptStats !== false) : (record.hasPromptStats !== false),
|
||||
|
||||
syncEnabled: synced,
|
||||
syncDeviceCount: deviceCount,
|
||||
syncUpdatedAt: aggregateData && aggregateData.updatedAt ? aggregateData.updatedAt : ""
|
||||
}
|
||||
}
|
||||
|
||||
function setting(name, fallback) {
|
||||
var value = settings ? settings[name] : undefined
|
||||
return value === undefined || value === null ? fallback : value
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------ sync
|
||||
|
||||
property var syncModeSetting: setting("syncMode", setting("syncEnabled", false))
|
||||
property bool syncEnabled: parseSyncEnabled(syncModeSetting)
|
||||
property string syncDir: String(setting("syncDir", ""))
|
||||
property string syncFileName: String(setting("syncFileName", ""))
|
||||
property string syncDeviceId: String(setting("syncDeviceId", ""))
|
||||
property string detectedHostname: ""
|
||||
readonly property string syncEffectiveDir: expandPath(syncDir)
|
||||
readonly property string syncEffectiveFileName: safeSnapshotFileName(syncFileName, syncDeviceId)
|
||||
readonly property string syncEffectiveDeviceId: safeDeviceId(syncDeviceId || syncEffectiveFileName.replace(/\.json$/i, ""))
|
||||
readonly property string syncSnapshotPath: syncConfigured() ? syncEffectiveDir + "/" + syncEffectiveFileName : home + "/.cache/omarchy/agents-disabled.json"
|
||||
property var aggregateData: ({})
|
||||
property int syncRevision: 0
|
||||
property bool syncRunning: false
|
||||
property bool syncRequestedWhileRunning: false
|
||||
property string syncStatusText: ""
|
||||
property double aggregateUpdatedAtMs: aggregateData && aggregateData.updatedAtMs ? Number(aggregateData.updatedAtMs) : 0
|
||||
|
||||
onSyncEnabledChanged: syncSettingsChanged()
|
||||
onSyncDirChanged: syncSettingsChanged()
|
||||
onSyncFileNameChanged: if (syncConfigured()) scheduleSync()
|
||||
onSyncDeviceIdChanged: if (syncConfigured()) scheduleSync()
|
||||
|
||||
Timer {
|
||||
id: syncDebounce
|
||||
interval: 1000
|
||||
repeat: false
|
||||
onTriggered: root.runSync()
|
||||
}
|
||||
|
||||
Process {
|
||||
id: syncMkdirProcess
|
||||
running: false
|
||||
onRunningChanged: root.updateSyncRunning()
|
||||
onExited: function(exitCode) {
|
||||
if (exitCode !== 0) {
|
||||
if (root.syncConfigured()) root.syncStatusText = "Usage sync mkdir failed"
|
||||
root.finishSyncRun()
|
||||
return
|
||||
}
|
||||
root.writeSyncSnapshot()
|
||||
}
|
||||
}
|
||||
|
||||
Process {
|
||||
id: syncScanProcess
|
||||
running: false
|
||||
onRunningChanged: root.updateSyncRunning()
|
||||
onExited: function(exitCode) {
|
||||
if (exitCode !== 0 && root.syncConfigured()) root.syncStatusText = "Usage sync scan failed"
|
||||
root.finishSyncRun()
|
||||
}
|
||||
|
||||
stdout: StdioCollector {
|
||||
waitForEnd: true
|
||||
onStreamFinished: root.parseSyncScanOutput(text)
|
||||
}
|
||||
|
||||
stderr: StdioCollector {
|
||||
waitForEnd: true
|
||||
onStreamFinished: if (text.trim() !== "") console.warn("agents/sync", text.trim())
|
||||
}
|
||||
}
|
||||
|
||||
FileView {
|
||||
id: syncSnapshotFile
|
||||
path: root.syncSnapshotPath
|
||||
watchChanges: false
|
||||
atomicWrites: true
|
||||
printErrors: false
|
||||
}
|
||||
|
||||
FileView {
|
||||
id: hostnameFile
|
||||
path: "/etc/hostname"
|
||||
watchChanges: false
|
||||
printErrors: false
|
||||
onLoaded: root.detectedHostname = String(text() || "").trim()
|
||||
}
|
||||
|
||||
function parseSyncEnabled(value) {
|
||||
if (value === true) return true
|
||||
var text = String(value || "").trim().toLowerCase()
|
||||
return text === "on" || text === "enabled" || text === "true" || text === "yes" || text === "1"
|
||||
}
|
||||
|
||||
function syncConfigured() {
|
||||
return root.syncEnabled === true && String(root.syncDir || "").trim() !== ""
|
||||
}
|
||||
|
||||
function syncSettingsChanged() {
|
||||
if (syncConfigured()) {
|
||||
scheduleSync()
|
||||
} else {
|
||||
syncDebounce.stop()
|
||||
syncRequestedWhileRunning = false
|
||||
aggregateData = ({})
|
||||
syncStatusText = ""
|
||||
syncRevision++
|
||||
}
|
||||
}
|
||||
|
||||
function updateSyncRunning() {
|
||||
root.syncRunning = syncMkdirProcess.running || syncScanProcess.running
|
||||
}
|
||||
|
||||
function scheduleSync() {
|
||||
if (!syncConfigured()) return
|
||||
syncDebounce.restart()
|
||||
}
|
||||
|
||||
function runSync() {
|
||||
if (!syncConfigured()) return
|
||||
if (root.syncRunning) {
|
||||
syncRequestedWhileRunning = true
|
||||
return
|
||||
}
|
||||
|
||||
syncRequestedWhileRunning = false
|
||||
syncStatusText = ""
|
||||
syncMkdirProcess.command = ["mkdir", "-p", root.syncEffectiveDir]
|
||||
syncMkdirProcess.running = true
|
||||
}
|
||||
|
||||
function writeSyncSnapshot() {
|
||||
if (!syncConfigured()) {
|
||||
finishSyncRun()
|
||||
return
|
||||
}
|
||||
syncSnapshotFile.setText(JSON.stringify(localSnapshot(), null, 2) + "\n")
|
||||
Qt.callLater(root.startSyncScan)
|
||||
}
|
||||
|
||||
function startSyncScan() {
|
||||
if (!syncConfigured()) {
|
||||
finishSyncRun()
|
||||
return
|
||||
}
|
||||
var script = "dir=$0; [[ -d \"$dir\" ]] || exit 0; shopt -s nullglob; for f in \"$dir\"/*.json; do [[ -f \"$f\" ]] || continue; printf '===%s===\\n' \"$f\"; cat \"$f\"; printf '\\n=== EOM ===\\n'; done"
|
||||
syncScanProcess.command = ["bash", "-c", script, root.syncEffectiveDir]
|
||||
syncScanProcess.running = true
|
||||
}
|
||||
|
||||
function finishSyncRun() {
|
||||
if (syncRequestedWhileRunning && syncConfigured()) {
|
||||
syncRequestedWhileRunning = false
|
||||
scheduleSync()
|
||||
}
|
||||
}
|
||||
|
||||
function expandPath(path) {
|
||||
var value = String(path || "").trim()
|
||||
if (value === "") return ""
|
||||
if (value === "~") return home
|
||||
if (value.indexOf("~/") === 0) return home + value.substring(1)
|
||||
if (value.indexOf("$HOME/") === 0) return home + value.substring(5)
|
||||
if (value.charAt(0) !== "/") return home + "/" + value
|
||||
return value
|
||||
}
|
||||
|
||||
function safeDeviceId(raw) {
|
||||
var value = String(raw || "").trim()
|
||||
if (value === "") value = Quickshell.env("HOSTNAME") || root.detectedHostname || Quickshell.env("HOST") || Quickshell.env("USER") || "device"
|
||||
value = value.replace(/[^A-Za-z0-9_.-]+/g, "-").replace(/^[._-]+|[._-]+$/g, "")
|
||||
if (value === "") value = "device"
|
||||
return value.length > 80 ? value.substring(0, 80) : value
|
||||
}
|
||||
|
||||
function safeSnapshotFileName(rawFileName, rawDeviceId) {
|
||||
var value = String(rawFileName || "").trim()
|
||||
if (value === "") value = safeDeviceId(rawDeviceId) + ".json"
|
||||
value = value.split("/").pop().replace(/[^A-Za-z0-9_.-]+/g, "-").replace(/^[._-]+|[._-]+$/g, "")
|
||||
if (value === "") value = safeDeviceId(rawDeviceId) + ".json"
|
||||
if (!/\.json$/i.test(value)) value += ".json"
|
||||
return value.length > 100 ? value.substring(0, 95) + ".json" : value
|
||||
}
|
||||
|
||||
function parseSyncScanOutput(output) {
|
||||
var lines = String(output || "").split("\n")
|
||||
var snapshots = []
|
||||
var currentPath = ""
|
||||
var currentJson = []
|
||||
|
||||
function flush() {
|
||||
if (currentPath === "") return
|
||||
var raw = currentJson.join("\n").trim()
|
||||
try {
|
||||
var parsed = JSON.parse(raw)
|
||||
if (parsed && parsed.providers) snapshots.push(parsed)
|
||||
} catch (e) {
|
||||
console.warn("agents/sync", "Ignoring bad snapshot", currentPath, e)
|
||||
}
|
||||
currentPath = ""
|
||||
currentJson = []
|
||||
}
|
||||
|
||||
for (var i = 0; i < lines.length; i++) {
|
||||
var line = lines[i]
|
||||
var start = line.match(/^===(.+)===$/)
|
||||
if (start && line !== "=== EOM ===") {
|
||||
flush()
|
||||
currentPath = start[1]
|
||||
currentJson = []
|
||||
continue
|
||||
}
|
||||
if (line === "=== EOM ===") {
|
||||
flush()
|
||||
continue
|
||||
}
|
||||
if (currentPath !== "") currentJson.push(line)
|
||||
}
|
||||
flush()
|
||||
|
||||
aggregateData = aggregateSnapshots(snapshots)
|
||||
syncStatusText = ""
|
||||
syncRevision++
|
||||
}
|
||||
|
||||
function cloneValue(value, fallback) {
|
||||
if (value === undefined || value === null) return fallback
|
||||
try {
|
||||
return JSON.parse(JSON.stringify(value))
|
||||
} catch (e) {
|
||||
return fallback
|
||||
}
|
||||
}
|
||||
|
||||
function numberValue(value) {
|
||||
var n = Number(value || 0)
|
||||
return isFinite(n) ? Math.round(n) : 0
|
||||
}
|
||||
|
||||
function dateString(date) {
|
||||
var y = date.getFullYear()
|
||||
var m = String(date.getMonth() + 1).padStart(2, "0")
|
||||
var d = String(date.getDate()).padStart(2, "0")
|
||||
return y + "-" + m + "-" + d
|
||||
}
|
||||
|
||||
function recentDateStrings() {
|
||||
var result = []
|
||||
for (var offset = 6; offset >= 0; offset--) {
|
||||
var date = new Date()
|
||||
date.setDate(date.getDate() - offset)
|
||||
result.push(dateString(date))
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
function emptyTokenBucket() {
|
||||
return { inputTokens: 0, outputTokens: 0, cacheReadInputTokens: 0, cacheCreationInputTokens: 0 }
|
||||
}
|
||||
|
||||
// Device-scoped stats add up across machines; account-scoped stats
|
||||
// (Fireworks' billing API) are replicas of the same upstream truth on
|
||||
// every synced device, so the widest value wins — summing them would
|
||||
// double every token per machine.
|
||||
function combineNumber(additive, current, value) {
|
||||
return additive ? numberValue(current) + numberValue(value) : Math.max(numberValue(current), numberValue(value))
|
||||
}
|
||||
|
||||
function combineObjectNumbers(additive, target, source) {
|
||||
if (!source) return
|
||||
for (var key in source) target[key] = combineNumber(additive, target[key], source[key])
|
||||
}
|
||||
|
||||
function aggregateSnapshots(snapshots) {
|
||||
var dates = recentDateStrings()
|
||||
var devices = {}
|
||||
var providers = {}
|
||||
|
||||
function providerAcc(id) {
|
||||
if (providers[id]) return providers[id]
|
||||
var recentByDay = {}
|
||||
for (var d = 0; d < dates.length; d++) recentByDay[dates[d]] = 0
|
||||
providers[id] = {
|
||||
providerId: id,
|
||||
providerName: "",
|
||||
ready: false,
|
||||
hasLocalStats: false,
|
||||
hasPromptStats: false,
|
||||
todayPrompts: 0,
|
||||
todaySessions: 0,
|
||||
todayTotalTokens: 0,
|
||||
todayTokensByModel: ({}),
|
||||
recentByDay: recentByDay,
|
||||
totalPrompts: 0,
|
||||
totalSessions: 0,
|
||||
activeDays: 0,
|
||||
activeDates: ({}),
|
||||
modelUsage: ({}),
|
||||
devices: ({})
|
||||
}
|
||||
return providers[id]
|
||||
}
|
||||
|
||||
for (var i = 0; i < snapshots.length; i++) {
|
||||
var snapshot = snapshots[i]
|
||||
var device = safeDeviceId(snapshot.deviceId || "device")
|
||||
devices[device] = true
|
||||
var snapshotProviders = snapshot.providers || {}
|
||||
for (var providerId in snapshotProviders) {
|
||||
var stats = snapshotProviders[providerId] || {}
|
||||
var acc = providerAcc(String(providerId))
|
||||
acc.devices[device] = true
|
||||
if (stats.providerName && acc.providerName === "") acc.providerName = String(stats.providerName)
|
||||
acc.ready = acc.ready || stats.ready === true
|
||||
acc.hasLocalStats = acc.hasLocalStats || stats.hasLocalStats !== false
|
||||
// Snapshots from before the field existed only came from agents that
|
||||
// count prompts, so a missing value reads as true.
|
||||
acc.hasPromptStats = acc.hasPromptStats || stats.hasPromptStats !== false
|
||||
var additive = String(stats.scope || "device") !== "account"
|
||||
acc.todayPrompts = combineNumber(additive, acc.todayPrompts, stats.todayPrompts)
|
||||
acc.todaySessions = combineNumber(additive, acc.todaySessions, stats.todaySessions)
|
||||
acc.todayTotalTokens = combineNumber(additive, acc.todayTotalTokens, stats.todayTotalTokens)
|
||||
acc.totalPrompts = combineNumber(additive, acc.totalPrompts, stats.totalPrompts)
|
||||
acc.totalSessions = combineNumber(additive, acc.totalSessions, stats.totalSessions)
|
||||
// Active days overlap between machines, so union the dates rather than
|
||||
// summing counts. Snapshots written before activeDates existed only
|
||||
// carry a count; the widest one stands in for them.
|
||||
var activeDates = Array.isArray(stats.activeDates) ? stats.activeDates : []
|
||||
for (var ad = 0; ad < activeDates.length; ad++) acc.activeDates[String(activeDates[ad])] = true
|
||||
acc.activeDays = Math.max(acc.activeDays, numberValue(stats.activeDays))
|
||||
combineObjectNumbers(additive, acc.todayTokensByModel, stats.todayTokensByModel || {})
|
||||
|
||||
var recent = Array.isArray(stats.recentDays) ? stats.recentDays : []
|
||||
for (var r = 0; r < recent.length; r++) {
|
||||
var day = recent[r] || {}
|
||||
var date = String(day.date || "")
|
||||
if (acc.recentByDay[date] !== undefined)
|
||||
acc.recentByDay[date] = combineNumber(additive, acc.recentByDay[date], day.messageCount)
|
||||
}
|
||||
|
||||
var usage = stats.modelUsage || {}
|
||||
for (var modelId in usage) {
|
||||
var bucket = acc.modelUsage[modelId]
|
||||
if (!bucket) bucket = acc.modelUsage[modelId] = emptyTokenBucket()
|
||||
combineObjectNumbers(additive, bucket, usage[modelId] || {})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
var outProviders = {}
|
||||
for (var id in providers) {
|
||||
var acc = providers[id]
|
||||
var recentDays = []
|
||||
for (var di = 0; di < dates.length; di++) recentDays.push({ date: dates[di], messageCount: acc.recentByDay[dates[di]] || 0 })
|
||||
var providerDevices = Object.keys(acc.devices).sort()
|
||||
outProviders[id] = {
|
||||
providerId: acc.providerId,
|
||||
providerName: acc.providerName,
|
||||
ready: acc.ready || providerDevices.length > 0,
|
||||
hasLocalStats: acc.hasLocalStats,
|
||||
hasPromptStats: acc.hasPromptStats,
|
||||
todayPrompts: acc.todayPrompts,
|
||||
todaySessions: acc.todaySessions,
|
||||
todayTotalTokens: acc.todayTotalTokens,
|
||||
todayTokensByModel: acc.todayTokensByModel,
|
||||
recentDays: recentDays,
|
||||
totalPrompts: acc.totalPrompts,
|
||||
totalSessions: acc.totalSessions,
|
||||
activeDays: Math.max(acc.activeDays, Object.keys(acc.activeDates).length),
|
||||
modelUsage: acc.modelUsage,
|
||||
deviceCount: providerDevices.length,
|
||||
devices: providerDevices
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
schemaVersion: 1,
|
||||
updatedAt: new Date().toISOString(),
|
||||
updatedAtMs: Date.now(),
|
||||
deviceCount: Object.keys(devices).length,
|
||||
devices: Object.keys(devices).sort(),
|
||||
providers: outProviders
|
||||
}
|
||||
}
|
||||
|
||||
// Snapshots keep the field names older Omarchy versions wrote, so a fleet
|
||||
// of machines on mixed versions still merges cleanly in both directions.
|
||||
function providerSnapshot(record) {
|
||||
return {
|
||||
providerId: String(record.id),
|
||||
providerName: String(record.name || record.id),
|
||||
ready: record.ready === true,
|
||||
hasLocalStats: record.hasLocalStats !== false,
|
||||
hasPromptStats: record.hasPromptStats !== false,
|
||||
scope: String(record.scope || "device"),
|
||||
todayPrompts: numberValue(record.todayPrompts),
|
||||
todaySessions: numberValue(record.todaySessions),
|
||||
todayTotalTokens: numberValue(record.todayTotalTokens),
|
||||
todayTokensByModel: cloneValue(record.todayTokensByModel, ({})),
|
||||
recentDays: cloneValue(record.recentDays, []),
|
||||
totalPrompts: numberValue(record.totalPrompts),
|
||||
totalSessions: numberValue(record.totalSessions),
|
||||
activeDays: numberValue(record.activeDays),
|
||||
activeDates: cloneValue(record.activeDates, []),
|
||||
modelUsage: cloneValue(record.modelUsage, ({}))
|
||||
}
|
||||
}
|
||||
|
||||
function localSnapshot() {
|
||||
var providerMap = {}
|
||||
for (var i = 0; i < agents.length; i++) {
|
||||
var record = agents[i] ? agents[i].record : null
|
||||
if (!record || !record.id) continue
|
||||
if (!providerEnabled(String(record.id))) continue
|
||||
providerMap[String(record.id)] = providerSnapshot(record)
|
||||
}
|
||||
return {
|
||||
schemaVersion: 1,
|
||||
deviceId: syncEffectiveDeviceId,
|
||||
updatedAt: new Date().toISOString(),
|
||||
providers: providerMap
|
||||
}
|
||||
}
|
||||
|
||||
function syncedStatsFor(providerId) {
|
||||
var rev = syncRevision
|
||||
if (!syncConfigured() || !aggregateData || !aggregateData.providers) return null
|
||||
return aggregateData.providers[providerId] || null
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- format
|
||||
|
||||
function formatTokenCount(n) {
|
||||
if (n === undefined || n === null) return "0"
|
||||
if (n >= 1e9) return (n / 1e9).toFixed(1) + "B"
|
||||
if (n >= 1e6) return (n / 1e6).toFixed(1) + "M"
|
||||
if (n >= 1e3) return (n / 1e3).toFixed(1) + "K"
|
||||
return String(n)
|
||||
}
|
||||
|
||||
function modelWordCase(word) {
|
||||
if (word === "gpt") return "GPT"
|
||||
if (word === "deepseek") return "DeepSeek"
|
||||
return word.charAt(0).toUpperCase() + word.slice(1)
|
||||
}
|
||||
|
||||
// Model ids arrive hyphenated with the version split across segments
|
||||
// (`claude-opus-4-8`, `gpt-5.6-sol`). Rejoin the numeric run into one
|
||||
// version and title-case the words around it.
|
||||
function friendlyModelName(id) {
|
||||
if (!id) return "Unknown"
|
||||
var name = String(id).replace(/^claude-/, "").replace(/-\d{8}$/, "")
|
||||
var parts = name.split("-")
|
||||
var words = []
|
||||
var version = []
|
||||
for (var i = 0; i < parts.length; i++) {
|
||||
var part = parts[i]
|
||||
if (part === "") continue
|
||||
if (/^\d/.test(part)) {
|
||||
version.push(part)
|
||||
continue
|
||||
}
|
||||
if (version.length > 0) {
|
||||
words.push(version.join("."))
|
||||
version = []
|
||||
}
|
||||
words.push(modelWordCase(part))
|
||||
}
|
||||
if (version.length > 0) words.push(version.join("."))
|
||||
return words.length > 0 ? words.join(" ") : "Unknown"
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user