@xerg/cli 0.0.1 → 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.
- package/README.md +65 -13
- package/dist/index.js +87 -0
- package/package.json +39 -8
- package/bin/xerg.js +0 -12
package/README.md
CHANGED
|
@@ -1,21 +1,73 @@
|
|
|
1
|
-
#
|
|
1
|
+
# xerg
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
Xerg is a local-first CLI for OpenClaw waste intelligence.
|
|
4
4
|
|
|
5
|
-
|
|
5
|
+
It does not try to be generic LLM observability. It reads your agent logs, shows
|
|
6
|
+
where money is leaking, and lets you re-run the same audit with `--compare` so
|
|
7
|
+
you can see what changed after a fix.
|
|
6
8
|
|
|
7
|
-
##
|
|
9
|
+
## Install
|
|
8
10
|
|
|
9
|
-
|
|
10
|
-
-
|
|
11
|
-
|
|
12
|
-
- Team dashboard at xerg.ai
|
|
11
|
+
```bash
|
|
12
|
+
npm install -g @xerg/cli
|
|
13
|
+
```
|
|
13
14
|
|
|
14
|
-
|
|
15
|
+
Or run it without a global install:
|
|
15
16
|
|
|
16
|
-
|
|
17
|
-
|
|
17
|
+
```bash
|
|
18
|
+
npx @xerg/cli audit
|
|
19
|
+
```
|
|
18
20
|
|
|
19
|
-
|
|
21
|
+
After a global install, run:
|
|
20
22
|
|
|
21
|
-
|
|
23
|
+
```bash
|
|
24
|
+
xerg audit
|
|
25
|
+
```
|
|
26
|
+
|
|
27
|
+
## Commands
|
|
28
|
+
|
|
29
|
+
Inspect local audit readiness:
|
|
30
|
+
|
|
31
|
+
```bash
|
|
32
|
+
xerg doctor
|
|
33
|
+
```
|
|
34
|
+
|
|
35
|
+
Run the first audit:
|
|
36
|
+
|
|
37
|
+
```bash
|
|
38
|
+
xerg audit
|
|
39
|
+
```
|
|
40
|
+
|
|
41
|
+
Compare the latest run against the newest compatible prior local snapshot:
|
|
42
|
+
|
|
43
|
+
```bash
|
|
44
|
+
xerg audit --compare
|
|
45
|
+
```
|
|
46
|
+
|
|
47
|
+
Audit a specific window:
|
|
48
|
+
|
|
49
|
+
```bash
|
|
50
|
+
xerg audit --since 24h --compare
|
|
51
|
+
```
|
|
52
|
+
|
|
53
|
+
## What Xerg reports today
|
|
54
|
+
|
|
55
|
+
- total spend
|
|
56
|
+
- observed vs estimated spend
|
|
57
|
+
- workflow and model breakdowns
|
|
58
|
+
- high-confidence waste
|
|
59
|
+
- directional savings opportunities
|
|
60
|
+
- before/after re-audit deltas
|
|
61
|
+
|
|
62
|
+
## Privacy
|
|
63
|
+
|
|
64
|
+
Xerg v0 stores economic metadata and audit summaries locally. It does not store
|
|
65
|
+
prompt or response content.
|
|
66
|
+
|
|
67
|
+
## Support
|
|
68
|
+
|
|
69
|
+
For beta access and support, contact `query@xerg.ai`.
|
|
70
|
+
|
|
71
|
+
## Website
|
|
72
|
+
|
|
73
|
+
[xerg.ai](https://xerg.ai)
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import{Command as we}from"commander";import vt from"picocolors";import{readFileSync as Bt}from"fs";import{createHash as Ot}from"crypto";function y(t){return Ot("sha1").update(t).digest("hex")}function Y(t){if(!t)return null;let e=t.trim().match(/^(\d+)([mhdw])$/i);if(!e)throw new Error(`Invalid --since value "${t}". Use values like 30m, 24h, 7d, 2w.`);let o=Number(e[1]),n=e[2].toLowerCase(),s={m:60*1e3,h:3600*1e3,d:1440*60*1e3,w:10080*60*1e3};return Date.now()-o*s[n]}function R(){return new Date().toISOString()}function D(t){if(typeof t=="string"){let e=new Date(t);if(!Number.isNaN(e.getTime()))return e.toISOString()}if(typeof t=="number"){let e=new Date(t);if(!Number.isNaN(e.getTime()))return e.toISOString()}return R()}import{mkdirSync as xt}from"fs";import{dirname as Ft}from"path";import Dt from"better-sqlite3";import{drizzle as Pt}from"drizzle-orm/better-sqlite3";import{integer as U,real as T,sqliteTable as I,text as p}from"drizzle-orm/sqlite-core";var q=I("source_files",{id:p("id").primaryKey(),path:p("path").notNull(),kind:p("kind").notNull(),fileHash:p("file_hash").notNull(),mtimeMs:U("mtime_ms").notNull(),sizeBytes:U("size_bytes").notNull(),importedAt:p("imported_at").notNull()}),Q=I("runs",{id:p("id").primaryKey(),sourcePath:p("source_path").notNull(),sourceKind:p("source_kind").notNull(),timestamp:p("timestamp").notNull(),workflow:p("workflow").notNull(),environment:p("environment").notNull(),tagsJson:p("tags_json").notNull(),totalCostUsd:T("total_cost_usd").notNull(),totalTokens:U("total_tokens").notNull(),observedCostUsd:T("observed_cost_usd").notNull(),estimatedCostUsd:T("estimated_cost_usd").notNull()}),V=I("calls",{id:p("id").primaryKey(),runId:p("run_id").notNull(),timestamp:p("timestamp").notNull(),provider:p("provider").notNull(),model:p("model").notNull(),inputTokens:U("input_tokens").notNull(),outputTokens:U("output_tokens").notNull(),costUsd:T("cost_usd").notNull(),costSource:p("cost_source").notNull(),latencyMs:U("latency_ms"),toolCalls:U("tool_calls").notNull(),retries:U("retries").notNull(),attempt:U("attempt"),iteration:U("iteration"),status:p("status"),taskClass:p("task_class"),cacheHit:U("cache_hit",{mode:"boolean"}).notNull(),cacheCostUsd:T("cache_cost_usd"),metadataJson:p("metadata_json").notNull()}),Z=I("findings",{id:p("id").primaryKey(),auditId:p("audit_id").notNull(),classification:p("classification").notNull(),confidence:p("confidence").notNull(),kind:p("kind").notNull(),title:p("title").notNull(),summary:p("summary").notNull(),scope:p("scope").notNull(),scopeId:p("scope_id").notNull(),costImpactUsd:T("cost_impact_usd").notNull(),detailsJson:p("details_json").notNull()}),tt=I("pricing_catalog",{id:p("id").primaryKey(),provider:p("provider").notNull(),model:p("model").notNull(),effectiveDate:p("effective_date").notNull(),inputPer1m:T("input_per_1m").notNull(),outputPer1m:T("output_per_1m").notNull(),cachedInputPer1m:T("cached_input_per_1m")}),_=I("audit_snapshots",{id:p("id").primaryKey(),createdAt:p("created_at").notNull(),summaryJson:p("summary_json").notNull()}),et=`
|
|
3
|
+
CREATE TABLE IF NOT EXISTS source_files (
|
|
4
|
+
id TEXT PRIMARY KEY,
|
|
5
|
+
path TEXT NOT NULL,
|
|
6
|
+
kind TEXT NOT NULL,
|
|
7
|
+
file_hash TEXT NOT NULL,
|
|
8
|
+
mtime_ms INTEGER NOT NULL,
|
|
9
|
+
size_bytes INTEGER NOT NULL,
|
|
10
|
+
imported_at TEXT NOT NULL
|
|
11
|
+
);
|
|
12
|
+
|
|
13
|
+
CREATE TABLE IF NOT EXISTS runs (
|
|
14
|
+
id TEXT PRIMARY KEY,
|
|
15
|
+
source_path TEXT NOT NULL,
|
|
16
|
+
source_kind TEXT NOT NULL,
|
|
17
|
+
timestamp TEXT NOT NULL,
|
|
18
|
+
workflow TEXT NOT NULL,
|
|
19
|
+
environment TEXT NOT NULL,
|
|
20
|
+
tags_json TEXT NOT NULL,
|
|
21
|
+
total_cost_usd REAL NOT NULL,
|
|
22
|
+
total_tokens INTEGER NOT NULL,
|
|
23
|
+
observed_cost_usd REAL NOT NULL,
|
|
24
|
+
estimated_cost_usd REAL NOT NULL
|
|
25
|
+
);
|
|
26
|
+
|
|
27
|
+
CREATE TABLE IF NOT EXISTS calls (
|
|
28
|
+
id TEXT PRIMARY KEY,
|
|
29
|
+
run_id TEXT NOT NULL,
|
|
30
|
+
timestamp TEXT NOT NULL,
|
|
31
|
+
provider TEXT NOT NULL,
|
|
32
|
+
model TEXT NOT NULL,
|
|
33
|
+
input_tokens INTEGER NOT NULL,
|
|
34
|
+
output_tokens INTEGER NOT NULL,
|
|
35
|
+
cost_usd REAL NOT NULL,
|
|
36
|
+
cost_source TEXT NOT NULL,
|
|
37
|
+
latency_ms INTEGER,
|
|
38
|
+
tool_calls INTEGER NOT NULL,
|
|
39
|
+
retries INTEGER NOT NULL,
|
|
40
|
+
attempt INTEGER,
|
|
41
|
+
iteration INTEGER,
|
|
42
|
+
status TEXT,
|
|
43
|
+
task_class TEXT,
|
|
44
|
+
cache_hit INTEGER NOT NULL,
|
|
45
|
+
cache_cost_usd REAL,
|
|
46
|
+
metadata_json TEXT NOT NULL
|
|
47
|
+
);
|
|
48
|
+
|
|
49
|
+
CREATE TABLE IF NOT EXISTS findings (
|
|
50
|
+
id TEXT PRIMARY KEY,
|
|
51
|
+
audit_id TEXT NOT NULL,
|
|
52
|
+
classification TEXT NOT NULL,
|
|
53
|
+
confidence TEXT NOT NULL,
|
|
54
|
+
kind TEXT NOT NULL,
|
|
55
|
+
title TEXT NOT NULL,
|
|
56
|
+
summary TEXT NOT NULL,
|
|
57
|
+
scope TEXT NOT NULL,
|
|
58
|
+
scope_id TEXT NOT NULL,
|
|
59
|
+
cost_impact_usd REAL NOT NULL,
|
|
60
|
+
details_json TEXT NOT NULL
|
|
61
|
+
);
|
|
62
|
+
|
|
63
|
+
CREATE TABLE IF NOT EXISTS pricing_catalog (
|
|
64
|
+
id TEXT PRIMARY KEY,
|
|
65
|
+
provider TEXT NOT NULL,
|
|
66
|
+
model TEXT NOT NULL,
|
|
67
|
+
effective_date TEXT NOT NULL,
|
|
68
|
+
input_per_1m REAL NOT NULL,
|
|
69
|
+
output_per_1m REAL NOT NULL,
|
|
70
|
+
cached_input_per_1m REAL
|
|
71
|
+
);
|
|
72
|
+
|
|
73
|
+
CREATE TABLE IF NOT EXISTS audit_snapshots (
|
|
74
|
+
id TEXT PRIMARY KEY,
|
|
75
|
+
created_at TEXT NOT NULL,
|
|
76
|
+
summary_json TEXT NOT NULL
|
|
77
|
+
);
|
|
78
|
+
`;function O(t){xt(Ft(t),{recursive:!0});let e=new Dt(t);return e.exec(et),{sqlite:e,db:Pt(e)}}function ot(t,e){let{db:o,sqlite:n}=O(e),s=R(),a=t.pricingCatalog.map(r=>({...r,cachedInputPer1m:r.cachedInputPer1m??null})),i=t.summary.sourceFiles.map(r=>({id:y(`${r.path}:${r.mtimeMs}:${r.sizeBytes}`),path:r.path,kind:r.kind,fileHash:y(Bt(r.path,"utf8")),mtimeMs:Math.trunc(r.mtimeMs),sizeBytes:r.sizeBytes,importedAt:s})),c=t.runs.map(r=>({id:r.id,sourcePath:r.sourcePath,sourceKind:r.sourceKind,timestamp:r.timestamp,workflow:r.workflow,environment:r.environment,tagsJson:JSON.stringify(r.tags),totalCostUsd:r.totalCostUsd,totalTokens:r.totalTokens,observedCostUsd:r.observedCostUsd,estimatedCostUsd:r.estimatedCostUsd})),l=t.runs.flatMap(r=>r.calls.map(u=>({id:u.id,runId:u.runId,timestamp:u.timestamp,provider:u.provider,model:u.model,inputTokens:u.inputTokens,outputTokens:u.outputTokens,costUsd:u.costUsd,costSource:u.costSource,latencyMs:u.latencyMs,toolCalls:u.toolCalls,retries:u.retries,attempt:u.attempt,iteration:u.iteration,status:u.status,taskClass:u.taskClass,cacheHit:u.cacheHit,cacheCostUsd:u.cacheCostUsd,metadataJson:JSON.stringify(u.metadata)}))),d=t.summary.findings.map(r=>({id:r.id,auditId:t.summary.auditId,classification:r.classification,confidence:r.confidence,kind:r.kind,title:r.title,summary:r.summary,scope:r.scope,scopeId:r.scopeId,costImpactUsd:r.costImpactUsd,detailsJson:JSON.stringify(r.details)}));a.length>0&&o.insert(tt).values(a).onConflictDoNothing().run(),i.length>0&&o.insert(q).values(i).onConflictDoNothing().run(),c.length>0&&o.insert(Q).values(c).onConflictDoNothing().run(),l.length>0&&o.insert(V).values(l).onConflictDoNothing().run(),d.length>0&&o.insert(Z).values(d).onConflictDoNothing().run(),o.insert(_).values({id:t.summary.auditId,createdAt:t.summary.generatedAt,summaryJson:JSON.stringify(t.summary)}).onConflictDoNothing().run(),n.close()}import{desc as Gt}from"drizzle-orm";var Mt={"retry-waste":"Retry waste","context-outlier":"Context bloat","loop-waste":"Loop waste","candidate-downgrade":"Downgrade candidates","idle-spend":"Idle waste"};function h(t){return Number(t.toFixed(6))}function Wt(t){if(!t)return"all";let e=t.trim().toLowerCase().match(/^(\d+)([mhdw])$/);return e?`${Number(e[1])}${e[2]}`:t.trim().toLowerCase()}function it(t){return t.replace(/\\/g,"/")}function Xt(t){let e=it(t),o="/sessions/",n=e.lastIndexOf(o);return n>=0?e.slice(0,n+o.length-1):e.slice(0,e.lastIndexOf("/"))||e}function Kt(t){let e=it(t);return e.slice(0,e.lastIndexOf("/"))||e}function jt(t){return Mt[t]??t}function zt(t){return t.kind==="sessions"?Xt(t.path):Kt(t.path)}function B(t){let e=Array.from(new Set(t.sources.map(n=>n.kind))).sort(),o=Array.from(new Set(t.sources.map(n=>`${n.kind}:${zt(n)}`))).sort();return y(JSON.stringify({kinds:e,roots:o,since:Wt(t.since)}))}function E(t,e){let o=new Map;for(let n of t){if(n.classification!==e)continue;let s=o.get(n.kind)??{kind:n.kind,label:jt(n.kind),classification:e,spendUsd:0,findingCount:0};s.spendUsd=h(s.spendUsd+n.costImpactUsd),s.findingCount+=1,o.set(n.kind,s)}return Array.from(o.values()).sort((n,s)=>s.spendUsd-n.spendUsd)}function nt(t){return new Map(t.map(e=>[e.key,e.spendUsd]))}function st(t,e){let o=nt(t),n=nt(e);return Array.from(new Set([...o.keys(),...n.keys()])).map(a=>{let i=n.get(a)??0,c=o.get(a)??0;return{key:a,baselineSpendUsd:h(i),currentSpendUsd:h(c),deltaSpendUsd:h(c-i)}}).filter(a=>a.deltaSpendUsd!==0).sort((a,i)=>Math.abs(i.deltaSpendUsd)-Math.abs(a.deltaSpendUsd)).slice(0,3)}function rt(t){return`${t.kind}:${t.scope}:${t.scopeId}`}function P(t){return t.sort((e,o)=>Math.abs(o.deltaCostImpactUsd)-Math.abs(e.deltaCostImpactUsd))}function Jt(t,e){let o=t.filter(d=>d.classification==="waste"&&d.confidence==="high"),n=e.filter(d=>d.classification==="waste"&&d.confidence==="high"),s=new Map(o.map(d=>[rt(d),d])),a=new Map(n.map(d=>[rt(d),d])),i=[],c=[],l=[];for(let[d,r]of s.entries()){let u=a.get(d);if(!u){i.push({kind:r.kind,title:r.title,scope:r.scope,scopeId:r.scopeId,currentCostImpactUsd:r.costImpactUsd,deltaCostImpactUsd:h(r.costImpactUsd)});continue}let m=h(r.costImpactUsd-u.costImpactUsd);m>0&&l.push({kind:r.kind,title:r.title,scope:r.scope,scopeId:r.scopeId,baselineCostImpactUsd:u.costImpactUsd,currentCostImpactUsd:r.costImpactUsd,deltaCostImpactUsd:m})}for(let[d,r]of a.entries())s.has(d)||c.push({kind:r.kind,title:r.title,scope:r.scope,scopeId:r.scopeId,baselineCostImpactUsd:r.costImpactUsd,deltaCostImpactUsd:h(-r.costImpactUsd)});return{newHighConfidenceWaste:P(i),resolvedHighConfidenceWaste:P(c),worsenedHighConfidenceWaste:P(l)}}function at(t){return{...t,comparisonKey:t.comparisonKey??B({sources:t.sourceFiles,since:t.since}),comparison:t.comparison??null,wasteByKind:t.wasteByKind?.length>0?t.wasteByKind:E(t.findings,"waste"),opportunityByKind:t.opportunityByKind?.length>0?t.opportunityByKind:E(t.findings,"opportunity"),notes:t.notes??[]}}function dt(t,e){let o=st(t.spendByWorkflow,e.spendByWorkflow),n=st(t.spendByModel,e.spendByModel);return{baselineAuditId:e.auditId,baselineGeneratedAt:e.generatedAt,baselineRunCount:e.runCount,baselineCallCount:e.callCount,baselineTotalSpendUsd:e.totalSpendUsd,baselineObservedSpendUsd:e.observedSpendUsd,baselineEstimatedSpendUsd:e.estimatedSpendUsd,baselineWasteSpendUsd:e.wasteSpendUsd,baselineOpportunitySpendUsd:e.opportunitySpendUsd,baselineStructuralWasteRate:e.structuralWasteRate,deltaTotalSpendUsd:h(t.totalSpendUsd-e.totalSpendUsd),deltaObservedSpendUsd:h(t.observedSpendUsd-e.observedSpendUsd),deltaEstimatedSpendUsd:h(t.estimatedSpendUsd-e.estimatedSpendUsd),deltaWasteSpendUsd:h(t.wasteSpendUsd-e.wasteSpendUsd),deltaOpportunitySpendUsd:h(t.opportunitySpendUsd-e.opportunitySpendUsd),deltaStructuralWasteRate:h(t.structuralWasteRate-e.structuralWasteRate),deltaRunCount:t.runCount-e.runCount,deltaCallCount:t.callCount-e.callCount,workflowDeltas:o,modelDeltas:n,findingChanges:Jt(t.findings,e.findings)}}function Ht(t){try{return at(JSON.parse(t))}catch{return null}}function Yt(t){let{db:e,sqlite:o}=O(t);try{return e.select({summaryJson:_.summaryJson}).from(_).orderBy(Gt(_.createdAt)).all().map(s=>Ht(s.summaryJson)).filter(s=>s!==null)}finally{o.close()}}function ct(t){return Yt(t.dbPath).find(e=>t.currentAuditId&&e.auditId===t.currentAuditId?!1:e.comparisonKey===t.comparisonKey)}import{statSync as Zt}from"fs";import K from"fast-glob";import{mkdirSync as M}from"fs";import{homedir as qt}from"os";import{join as lt}from"path";import Qt from"env-paths";function Vt(){let t=Qt("xerg",{suffix:""});return M(t.data,{recursive:!0}),M(t.config,{recursive:!0}),M(t.cache,{recursive:!0}),t}function ut(){return lt(Vt().data,"xerg.db")}function W(){return lt(qt(),".openclaw","agents","*","sessions","*.jsonl")}function X(){return"/tmp/openclaw/openclaw-*.log"}function x(t,e){try{let o=Zt(t);return o.isFile()?{kind:e,path:t,sizeBytes:o.size,mtimeMs:o.mtimeMs}:null}catch{return null}}async function j(t){let e=[];if(t.logFile){let a=x(t.logFile,"gateway");a&&e.push(a)}if(t.sessionsDir){let a=await K("**/*.jsonl",{absolute:!0,cwd:t.sessionsDir,onlyFiles:!0});for(let i of a){let c=x(i,"sessions");c&&e.push(c)}}if(e.length>0)return e.sort((a,i)=>i.mtimeMs-a.mtimeMs);let[o,n]=await Promise.all([K(X(),{absolute:!0,onlyFiles:!0}),K(W(),{absolute:!0,onlyFiles:!0})]);return[...o.map(a=>x(a,"gateway")).filter(Boolean),...n.map(a=>x(a,"sessions")).filter(Boolean)].sort((a,i)=>i.mtimeMs-a.mtimeMs)}async function pt(t){let e=await j(t),o=[];return e.length===0&&(o.push("No OpenClaw gateway logs or session files were detected."),o.push("Use --log-file or --sessions-dir if your OpenClaw data lives outside the defaults.")),e.some(n=>n.kind==="gateway")&&o.push("Gateway logs detected. These are preferred when cost metadata is present."),e.some(n=>n.kind==="sessions")&&o.push("Session transcript fallback detected. Xerg will extract usage metadata only."),{canAudit:e.length>0,sources:e,defaults:{gatewayPattern:X(),sessionsPattern:W()},notes:o}}function v(t){return{...t,id:y(`${t.kind}:${t.scope}:${t.scopeId}:${t.title}:${t.costImpactUsd}:${t.summary}`)}}function b(t){return Number(t.toFixed(6))}function mt(t){let e=[],n=t.flatMap(i=>i.calls.map(c=>({run:i,call:c}))).filter(({call:i})=>{let c=(i.status??"").toLowerCase();return c.includes("error")||c.includes("fail")}),s=n.reduce((i,c)=>i+c.call.costUsd,0);s>0&&e.push(v({classification:"waste",confidence:"high",kind:"retry-waste",title:"Retry waste is consuming measurable spend",summary:`${n.length} failed call${n.length===1?"":"s"} were followed by additional work, making their spend pure retry overhead.`,scope:"global",scopeId:"all",costImpactUsd:b(s),details:{failedCallCount:n.length}}));for(let i of t){let c=Math.max(...i.calls.map(l=>l.iteration??0));if(c>=7){let d=i.calls.filter(r=>(r.iteration??0)>5).reduce((r,u)=>r+u.costUsd,0);e.push(v({classification:"waste",confidence:"high",kind:"loop-waste",title:`Workflow "${i.workflow}" ran beyond efficient loop bounds`,summary:`This run reached ${c} iterations. Xerg treats the spend after iteration 5 as likely loop waste.`,scope:"run",scopeId:i.id,costImpactUsd:b(d),details:{workflow:i.workflow,maxIteration:c}}))}}let a=new Map;for(let i of t){let c=a.get(i.workflow)??[];c.push(i),a.set(i.workflow,c)}for(let[i,c]of a.entries()){if(c.length>=3){let r=c.map(w=>w.calls.reduce((S,C)=>S+C.inputTokens,0)),u=r.reduce((w,S)=>w+S,0)/r.length,m=c.filter(w=>{let S=w.calls.reduce((C,At)=>C+At.inputTokens,0);return S>u*1.75&&S>1500});if(m.length>0){let w=m.reduce((S,C)=>S+C.totalCostUsd,0);e.push(v({classification:"opportunity",confidence:"medium",kind:"context-outlier",title:`Context usage in "${i}" is well above its baseline`,summary:`Xerg found ${m.length} run${m.length===1?"":"s"} in this workflow with input token volume far above the workflow average.`,scope:"workflow",scopeId:i,costImpactUsd:b(w),details:{workflow:i,averageInputTokens:b(u),outlierRunCount:m.length}}))}}let l=c.filter(r=>/(heartbeat|cron|monitor|poll)/i.test(r.workflow));if(l.length>0){let r=l.reduce((u,m)=>u+m.totalCostUsd,0);e.push(v({classification:"opportunity",confidence:"medium",kind:"idle-spend",title:`Idle or monitoring spend detected in "${i}"`,summary:"This workflow name looks like a recurring heartbeat or monitoring loop. Review whether the cadence and model tier are justified.",scope:"workflow",scopeId:i,costImpactUsd:b(r),details:{workflow:i}}))}let d=c.flatMap(r=>r.calls).filter(r=>/(opus|gpt-4o|sonnet)/i.test(r.model)&&/(heartbeat|cron|monitor|summary|tag|triage)/i.test(r.taskClass??i));if(d.length>0){let r=d.reduce((u,m)=>u+m.costUsd,0);e.push(v({classification:"opportunity",confidence:"low",kind:"candidate-downgrade",title:`Candidate model downgrade opportunity in "${i}"`,summary:"An expensive model is being used on a workflow that looks operationally simple. Treat this as an A/B test candidate, not proven waste.",scope:"workflow",scopeId:i,costImpactUsd:b(r*.3),details:{workflow:i,expensiveCallCount:d.length,inspectedSpendUsd:b(r)}}))}}return e.sort((i,c)=>c.costImpactUsd-i.costImpactUsd)}import{readFileSync as ee}from"fs";import{basename as oe}from"path";var z=[{id:"anthropic-claude-haiku-4-5-2026-03-01",provider:"anthropic",model:"claude-haiku-4-5",effectiveDate:"2026-03-01",inputPer1m:.8,outputPer1m:4},{id:"anthropic-claude-sonnet-4-5-2026-03-01",provider:"anthropic",model:"claude-sonnet-4-5",effectiveDate:"2026-03-01",inputPer1m:3,outputPer1m:15},{id:"anthropic-claude-opus-4-2026-03-01",provider:"anthropic",model:"claude-opus-4",effectiveDate:"2026-03-01",inputPer1m:15,outputPer1m:75},{id:"openai-gpt-4o-2026-03-01",provider:"openai",model:"gpt-4o",effectiveDate:"2026-03-01",inputPer1m:2.5,outputPer1m:10},{id:"openai-gpt-4.1-mini-2026-03-01",provider:"openai",model:"gpt-4.1-mini",effectiveDate:"2026-03-01",inputPer1m:.4,outputPer1m:1.6},{id:"google-gemini-2.0-flash-2026-03-01",provider:"google",model:"gemini-2.0-flash",effectiveDate:"2026-03-01",inputPer1m:.35,outputPer1m:1.4},{id:"meta-llama-3.3-70b-2026-03-01",provider:"meta",model:"llama-3.3-70b-instruct",effectiveDate:"2026-03-01",inputPer1m:.9,outputPer1m:.9}];function te(t,e){let o=t.trim().toLowerCase(),n=e.trim().toLowerCase();return z.find(s=>s.provider.toLowerCase()===o&&s.model.toLowerCase()===n)}function ft(t,e,o,n){let s=te(t,e);if(!s)return null;let a=Math.max(o,0)/1e6*s.inputPer1m,i=Math.max(n,0)/1e6*s.outputPer1m;return Number((a+i).toFixed(8))}function g(t,e){if(!t||typeof t!="object")return null;let o=t;for(let n of e){let s=o;for(let a of n){if(!s||typeof s!="object"||!(a in s)){s=void 0;break}s=s[a]}if(s!==void 0)return s}return null}function k(t){if(typeof t=="number"&&Number.isFinite(t))return t;if(typeof t=="string"&&t.trim()!==""){let e=Number(t);return Number.isFinite(e)?e:null}return null}function N(t){return typeof t=="string"&&t.trim()!==""?t.trim():null}function gt(t){return typeof t=="boolean"?t:typeof t=="string"?["true","1","yes"].includes(t.trim().toLowerCase()):typeof t=="number"?t>0:!1}function wt(t,e){let o={};for(let n of e){let s=t[n];(typeof s=="string"||typeof s=="number"||typeof s=="boolean")&&(o[n]=s)}return o}function ne(t){let o=ee(t,"utf8").split(/\r?\n/).map(s=>s.trim()).filter(Boolean),n=[];for(let s of o)try{let a=JSON.parse(s);n.push(a)}catch{}return n}function se(t){return N(g(t,[["provider"],["message","provider"],["usage","provider"]]))??"unknown"}function re(t){return N(g(t,[["model"],["message","model"],["usage","model"]]))??"unknown-model"}function ht(t,e){return N(g(t,[["workflow"],["session","workflow"],["metadata","workflow"],["agent","name"],["agentId"],["sessionId"]]))??oe(e,".jsonl")}function ie(t){return N(g(t,[["environment"],["env"],["metadata","environment"]]))??"local"}function ae(t,e,o,n){return N(g(t,[["run_id"],["runId"],["trace_id"],["traceId"],["sessionId"],["thread_id"]]))??`${n}:${e}:${o}`}function de(t,e){return N(g(t,[["task_class"],["taskClass"],["metadata","taskClass"]]))??e.toLowerCase()}function F(t){let e=k(g(t,[["input_tokens"],["inputTokens"],["usage","input_tokens"],["usage","inputTokens"],["message","usage","input_tokens"],["message","usage","inputTokens"],["usage","prompt_tokens"],["message","usage","prompt_tokens"]]))??0,o=k(g(t,[["output_tokens"],["outputTokens"],["usage","output_tokens"],["usage","outputTokens"],["message","usage","output_tokens"],["message","usage","outputTokens"],["usage","completion_tokens"],["message","usage","completion_tokens"]]))??0,n=k(g(t,[["cost_usd"],["costUsd"],["usage","cost_usd"],["usage","costUsd"],["usage","cost","total"],["message","usage","cost","total"],["message","usage","cost_usd"],["pricing","total_usd"]]))??null;return{inputTokens:e,outputTokens:o,observedCost:n}}function ce(t,e,o,n){let s=se(e),a=re(e),i=ht(e,t.path),{inputTokens:c,outputTokens:l,observedCost:d}=F(e),r=ft(s,a,c,l),u=D(g(e,[["timestamp"],["createdAt"],["created_at"]])),m=k(g(e,[["attempt"],["usage","attempt"],["metadata","attempt"]]))??null,w=k(g(e,[["iteration"],["loop_iteration"],["metadata","iteration"]]))??null,S=k(g(e,[["retries"],["retry_count"],["metadata","retries"]]))??0,C=d??r??0;return{id:y(`${o}:${t.path}:${n}:${a}:${u}:${C}`),runId:o,timestamp:u,provider:s,model:a,inputTokens:c,outputTokens:l,costUsd:C,costSource:d!==null?"observed":"estimated",latencyMs:k(g(e,[["latency_ms"],["latencyMs"],["usage","latency_ms"]]))??null,toolCalls:k(g(e,[["tool_calls"],["toolCalls"],["usage","tool_calls"]]))??0,retries:S,attempt:m,iteration:w,status:N(g(e,[["status"],["level"],["result"],["error","type"]]))??null,taskClass:de(e,i),cacheHit:gt(g(e,[["cache_hit"],["cacheHit"],["usage","cache_hit"]])),cacheCostUsd:k(g(e,[["cache_cost_usd"],["cacheCostUsd"],["usage","cache_cost_usd"]]))??null,metadata:wt(e,["event","type","sessionId","agentId"])}}function le(t){return F(t).inputTokens>0||F(t).outputTokens>0||F(t).observedCost!==null}function yt(t,e){let o=Y(e),n=new Map;for(let s of t)ne(s.path).forEach((i,c)=>{if(!le(i))return;let l=ht(i,s.path),d=D(g(i,[["timestamp"],["createdAt"],["created_at"]]));if(o&&new Date(d).getTime()<o)return;let r=ae(i,l,c,s.path),u=y(`${s.path}:${r}`),m=ce(s,i,u,c),w=n.get(u);if(!w){n.set(u,{id:u,sourceKind:s.kind,sourcePath:s.path,timestamp:d,workflow:l,environment:ie(i),tags:{sourceKind:s.kind},calls:[m],totalCostUsd:m.costUsd,totalTokens:m.inputTokens+m.outputTokens,observedCostUsd:m.costSource==="observed"?m.costUsd:0,estimatedCostUsd:m.costSource==="estimated"?m.costUsd:0});return}w.calls.push(m),w.totalCostUsd=Number((w.totalCostUsd+m.costUsd).toFixed(8)),w.totalTokens+=m.inputTokens+m.outputTokens,w.observedCostUsd+=m.costSource==="observed"?m.costUsd:0,w.estimatedCostUsd+=m.costSource==="estimated"?m.costUsd:0});return Array.from(n.values()).sort((s,a)=>new Date(s.timestamp).getTime()-new Date(a.timestamp).getTime())}function Ut(t){let e=new Map;for(let o of t){let n=e.get(o.key)??{spendUsd:0,observedSpendUsd:0,callCount:0};n.spendUsd+=o.spendUsd,n.observedSpendUsd+=o.observedSpendUsd,n.callCount+=1,e.set(o.key,n)}return Array.from(e.entries()).map(([o,n])=>{let s=n.spendUsd===0?0:n.observedSpendUsd/n.spendUsd;return{key:o,spendUsd:Number(n.spendUsd.toFixed(6)),callCount:n.callCount,observedShare:Number(s.toFixed(4))}}).sort((o,n)=>n.spendUsd-o.spendUsd)}function St(t){let e=t.runs.reduce((l,d)=>l+d.calls.length,0),o=t.runs.reduce((l,d)=>l+d.totalCostUsd,0),n=t.runs.reduce((l,d)=>l+d.observedCostUsd,0),s=t.runs.reduce((l,d)=>l+d.estimatedCostUsd,0),a=t.findings.filter(l=>l.classification==="waste").reduce((l,d)=>l+d.costImpactUsd,0),i=t.findings.filter(l=>l.classification==="opportunity").reduce((l,d)=>l+d.costImpactUsd,0),c=R();return{auditId:y(`${c}:${t.runs.length}:${t.sources.map(l=>l.path).join("|")}`),generatedAt:c,comparisonKey:B({sources:t.sources,since:t.since}),comparison:null,since:t.since,runCount:t.runs.length,callCount:e,totalSpendUsd:Number(o.toFixed(6)),observedSpendUsd:Number(n.toFixed(6)),estimatedSpendUsd:Number(s.toFixed(6)),wasteSpendUsd:Number(a.toFixed(6)),opportunitySpendUsd:Number(i.toFixed(6)),structuralWasteRate:Number((o===0?0:a/o).toFixed(4)),wasteByKind:E(t.findings,"waste"),opportunityByKind:E(t.findings,"opportunity"),spendByWorkflow:Ut(t.runs.map(l=>({key:l.workflow,spendUsd:l.totalCostUsd,observedSpendUsd:l.observedCostUsd}))),spendByModel:Ut(t.runs.flatMap(l=>l.calls.map(d=>({key:`${d.provider}/${d.model}`,spendUsd:d.costUsd,observedSpendUsd:d.costSource==="observed"?d.costUsd:0})))),findings:t.findings,notes:["Cost per outcome is intentionally unavailable in v0. Xerg is measuring waste intelligence only.","Opportunity findings are directional recommendations, not proven waste."],sourceFiles:t.sources,dbPath:t.dbPath}}async function kt(t){return pt(t)}async function Tt(t){if(t.compare&&t.noDb)throw new Error("The --compare flag needs local snapshot history. Remove --no-db or provide --db <path>.");let e=await j(t);if(e.length===0)throw new Error("No OpenClaw sources were detected. Run `xerg doctor` or provide --log-file / --sessions-dir.");let o=yt(e,t.since),n=mt(o),s=t.noDb?void 0:t.dbPath??ut(),a=St({runs:o,findings:n,sources:e,since:t.since,dbPath:s});if(t.compare&&s){let i=ct({dbPath:s,comparisonKey:a.comparisonKey,currentAuditId:a.auditId});i?a.comparison=dt(a,i):a.notes=[...a.notes,"No prior comparable audit was found. Run the same audit again after a fix to unlock before/after deltas."]}return s&&ot({summary:a,runs:o,pricingCatalog:z},s),a}function f(t){return new Intl.NumberFormat("en-US",{style:"currency",currency:"USD",minimumFractionDigits:t>=1?2:4,maximumFractionDigits:4}).format(t)}function $(t){return`${(t*100).toFixed(0)}%`}function bt(t){let e=t*100;return`${e>0?"+":""}${e.toFixed(0)} pts`}function L(t){return`${t>0?"+":""}${f(t)}`}function H(t,e=5){return t.slice(0,e).map(o=>`- ${o.key}: ${f(o.spendUsd)} (${$(o.observedShare)} observed)`)}function Ct(t,e,o){return t.length===0?[`- ${e}`]:t.map(n=>{let s=`${n.findingCount} finding${n.findingCount===1?"":"s"}`,a=o?` ${o}`:"";return`- ${n.label}: ${f(n.spendUsd)} across ${s}${a}`})}function $t(t){return["## Waste taxonomy","Structural waste",...Ct(t.wasteByKind,"No confirmed waste buckets detected."),"Savings opportunities",...Ct(t.opportunityByKind,"No opportunity buckets detected.","(directional)")]}function ue(t,e){return t.findings.filter(o=>o.classification===e).sort((o,n)=>n.costImpactUsd-o.costImpactUsd)[0]}function pe(t){return t.findings.filter(e=>e.classification==="opportunity").sort((e,o)=>{let n=e.kind==="candidate-downgrade"?1:0,s=o.kind==="candidate-downgrade"?1:0;return n!==s?s-n:o.costImpactUsd-e.costImpactUsd})[0]??null}function Nt(t,e){return t.length===0?[`- ${e}`]:t.slice(0,5).map(o=>`- ${o.title}: ${f(o.costImpactUsd)} (${o.confidence})`)}function J(t){return`${t.key} (${L(t.deltaSpendUsd)})`}function me(t){return t.filter(e=>e.deltaSpendUsd<0).sort((e,o)=>e.deltaSpendUsd-o.deltaSpendUsd)[0]}function fe(t){return t.filter(e=>e.deltaSpendUsd>0).sort((e,o)=>o.deltaSpendUsd-e.deltaSpendUsd)[0]}function G(t,e){return e==="resolved"?`- Resolved: ${t.title} (${f(t.baselineCostImpactUsd??0)})`:e==="worsened"?`- Worsened: ${t.title} (${L(t.deltaCostImpactUsd)})`:`- New: ${t.title} (${f(t.currentCostImpactUsd??0)})`}function ge(t){if(!t.comparison)return[];let e=t.comparison,o=me(e.workflowDeltas),n=fe(e.workflowDeltas),s=n?.key??t.spendByWorkflow[0]?.key??null,a=[...e.findingChanges.newHighConfidenceWaste.map(i=>G(i,"new")),...e.findingChanges.resolvedHighConfidenceWaste.map(i=>G(i,"resolved")),...e.findingChanges.worsenedHighConfidenceWaste.map(i=>G(i,"worsened"))].slice(0,5);return["## Before / after",`Compared against ${e.baselineGeneratedAt}`,`- Total spend: ${f(e.baselineTotalSpendUsd)} -> ${f(t.totalSpendUsd)} (${L(e.deltaTotalSpendUsd)})`,`- Structural waste: ${f(e.baselineWasteSpendUsd)} -> ${f(t.wasteSpendUsd)} (${L(e.deltaWasteSpendUsd)})`,`- Waste rate: ${$(e.baselineStructuralWasteRate)} -> ${$(t.structuralWasteRate)} (${bt(e.deltaStructuralWasteRate)})`,`- Runs analyzed: ${e.baselineRunCount} -> ${t.runCount} (${e.deltaRunCount>0?"+":""}${e.deltaRunCount})`,`- Model calls: ${e.baselineCallCount} -> ${t.callCount} (${e.deltaCallCount>0?"+":""}${e.deltaCallCount})`,o?`- Biggest improvement: ${J(o)}`:"- Biggest improvement: none detected",n?`- Biggest regression: ${J(n)}`:"- Biggest regression: none detected",s?`- First workflow to inspect now: ${s}`:"- First workflow to inspect now: no workflow delta available",...e.modelDeltas.length>0?[`- Model swing to inspect: ${J(e.modelDeltas[0])}`]:["- Model swing to inspect: none"],...a.length>0?a:["- High-confidence waste changes: none"]]}function It(t){return["# Xerg doctor","",t.canAudit?"OpenClaw sources detected.":"No OpenClaw sources detected.","","## Defaults",`- gateway logs: ${t.defaults.gatewayPattern}`,`- session files: ${t.defaults.sessionsPattern}`,"","## Sources",...t.sources.length>0?t.sources.map(o=>`- [${o.kind}] ${o.path}`):["- none"],"","## Notes",...t.notes.map(o=>`- ${o}`)].join(`
|
|
79
|
+
`)}function _t(t){let e=t.findings.filter(a=>a.classification==="waste"),o=t.findings.filter(a=>a.classification==="opportunity"),n=pe(t),s=ue(t,"waste");return["# Xerg audit","",`Total spend: ${f(t.totalSpendUsd)}`,`Observed spend: ${f(t.observedSpendUsd)}`,`Estimated spend: ${f(t.estimatedSpendUsd)}`,`Runs analyzed: ${t.runCount}`,`Model calls: ${t.callCount}`,`Structural waste identified: ${f(t.wasteSpendUsd)} (${$(t.structuralWasteRate)})`,`Potential impact surfaced: ${f(t.opportunitySpendUsd)}`,"",...$t(t),"","## Top workflows",...H(t.spendByWorkflow),"","## Top models",...H(t.spendByModel),"","## High-confidence waste",...Nt(e,"none detected"),"","## Opportunities",...Nt(o,"none detected"),"","## First savings test",...n?[`- Start with ${n.title}: ${f(n.costImpactUsd)} of potential impact`,`- Why this test first: ${n.summary}`]:["- No savings test surfaced yet"],...s?[`- Confirmed leak to close first: ${s.title}`]:["- Confirmed leak to close first: none"],...t.spendByWorkflow[0]?[`- Workflow to inspect first: ${t.spendByWorkflow[0].key}`]:["- Workflow to inspect first: none"],"",...ge(t),...t.comparison?[""]:[],"## Notes",...t.notes.map(a=>`- ${a}`)].join(`
|
|
80
|
+
`)}function Lt(t){let e=["# Xerg Audit Report","",`- Generated: ${t.generatedAt}`,`- Total spend: ${f(t.totalSpendUsd)}`,`- Observed spend: ${f(t.observedSpendUsd)}`,`- Estimated spend: ${f(t.estimatedSpendUsd)}`,`- Structural waste identified: ${f(t.wasteSpendUsd)} (${$(t.structuralWasteRate)})`,`- Potential impact surfaced: ${f(t.opportunitySpendUsd)}`,`- Runs analyzed: ${t.runCount}`,`- Model calls: ${t.callCount}`,"",...$t(t),"","## Top workflows",...H(t.spendByWorkflow),"","## Findings",...t.findings.slice(0,10).map(o=>`- **${o.title}** (${o.classification}, ${o.confidence}) \u2014 ${o.summary} Estimated impact: ${f(o.costImpactUsd)}.`)];if(t.comparison){let o=t.comparison;e.push("","## Before / after",`- Compared against: ${o.baselineGeneratedAt}`,`- Total spend: ${f(o.baselineTotalSpendUsd)} -> ${f(t.totalSpendUsd)} (${L(o.deltaTotalSpendUsd)})`,`- Structural waste: ${f(o.baselineWasteSpendUsd)} -> ${f(t.wasteSpendUsd)} (${L(o.deltaWasteSpendUsd)})`,`- Waste rate: ${$(o.baselineStructuralWasteRate)} -> ${$(t.structuralWasteRate)} (${bt(o.deltaStructuralWasteRate)})`)}return e.join(`
|
|
81
|
+
`)}async function Rt(t){let e=await Tt({logFile:t.logFile,sessionsDir:t.sessionsDir,since:t.since,compare:t.compare,dbPath:t.db,noDb:t.noDb});if(t.json){process.stdout.write(`${JSON.stringify(e,null,2)}
|
|
82
|
+
`);return}if(t.markdown){process.stdout.write(`${Lt(e)}
|
|
83
|
+
`);return}process.stdout.write(`${_t(e)}
|
|
84
|
+
`)}async function Et(t){let e=await kt({logFile:t.logFile,sessionsDir:t.sessionsDir});process.stdout.write(`${It(e)}
|
|
85
|
+
`)}var A=new we;A.name("xerg").description("Waste intelligence for OpenClaw workflows.").version("0.1.0");A.command("audit").description("Analyze OpenClaw logs and produce a waste intelligence report.").option("--log-file <path>","explicit OpenClaw gateway log file to analyze").option("--sessions-dir <path>","explicit OpenClaw sessions directory to analyze").option("--since <duration>","look back window such as 24h, 7d, or 30m").option("--compare","compare this audit to the newest compatible prior local snapshot").option("--json","render the report as JSON").option("--markdown","render the report as Markdown").option("--db <path>","custom SQLite database path").option("--no-db","skip local persistence").action(async t=>{if(t.json&&t.markdown)throw new Error("Use either --json or --markdown, not both.");await Rt(t)});A.command("doctor").description("Inspect your machine for OpenClaw sources and audit readiness.").option("--log-file <path>","explicit OpenClaw gateway log file to inspect").option("--sessions-dir <path>","explicit OpenClaw sessions directory to inspect").action(async t=>{await Et(t)});A.configureOutput({outputError:(t,e)=>{e(`${vt.red(t)}
|
|
86
|
+
`)}});A.parseAsync(process.argv).catch(t=>{let e=t instanceof Error?t.message:"Unknown error";process.stderr.write(`${vt.red(`xerg failed: ${e}`)}
|
|
87
|
+
`),process.exitCode=1});
|
package/package.json
CHANGED
|
@@ -1,20 +1,51 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@xerg/cli",
|
|
3
|
-
"version": "0.0
|
|
4
|
-
"description": "
|
|
5
|
-
"keywords": ["ai", "
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "CLI waste intelligence for OpenClaw workflows.",
|
|
5
|
+
"keywords": ["ai", "agents", "finops", "llm", "openclaw", "cost", "cli"],
|
|
6
6
|
"homepage": "https://xerg.ai",
|
|
7
7
|
"repository": {
|
|
8
8
|
"type": "git",
|
|
9
|
-
"url": "https://github.com/
|
|
9
|
+
"url": "git+https://github.com/xergai/xerg.git",
|
|
10
|
+
"directory": "packages/cli"
|
|
10
11
|
},
|
|
12
|
+
"bugs": {
|
|
13
|
+
"email": "query@xerg.ai",
|
|
14
|
+
"url": "https://xerg.ai"
|
|
15
|
+
},
|
|
16
|
+
"author": "Xerg <query@xerg.ai>",
|
|
11
17
|
"license": "MIT",
|
|
12
|
-
"
|
|
18
|
+
"type": "module",
|
|
13
19
|
"bin": {
|
|
14
|
-
"xerg": "
|
|
20
|
+
"xerg": "dist/index.js"
|
|
21
|
+
},
|
|
22
|
+
"files": ["dist", "README.md", "LICENSE"],
|
|
23
|
+
"main": "./dist/index.js",
|
|
24
|
+
"exports": {
|
|
25
|
+
".": {
|
|
26
|
+
"default": "./dist/index.js"
|
|
27
|
+
}
|
|
15
28
|
},
|
|
16
|
-
"files": ["bin", "README.md", "LICENSE"],
|
|
17
29
|
"engines": {
|
|
18
|
-
"node": "
|
|
30
|
+
"node": "24.x"
|
|
31
|
+
},
|
|
32
|
+
"scripts": {
|
|
33
|
+
"build": "tsup",
|
|
34
|
+
"dev": "tsx src/index.ts",
|
|
35
|
+
"typecheck": "tsc --noEmit -p tsconfig.json"
|
|
36
|
+
},
|
|
37
|
+
"dependencies": {
|
|
38
|
+
"better-sqlite3": "^11.10.0",
|
|
39
|
+
"commander": "^14.0.0",
|
|
40
|
+
"drizzle-orm": "^0.44.2",
|
|
41
|
+
"env-paths": "^3.0.0",
|
|
42
|
+
"fast-glob": "^3.3.3",
|
|
43
|
+
"picocolors": "^1.1.1",
|
|
44
|
+
"zod": "^3.24.2"
|
|
45
|
+
},
|
|
46
|
+
"devDependencies": {
|
|
47
|
+
"@xergai/core": "workspace:*",
|
|
48
|
+
"tsup": "^8.4.0",
|
|
49
|
+
"tsx": "^4.20.3"
|
|
19
50
|
}
|
|
20
51
|
}
|